Skip to content

fix: fallbfix: fallback for streaming tool_call args lost when proxy omits index fieldck for streaming tool_call args lost when proxy omits inde…#6662

Open
jameswangchen wants to merge 1 commit intoAstrBotDevs:masterfrom
jameswangchen:fix/gemini-streaming-toolcall
Open

fix: fallbfix: fallback for streaming tool_call args lost when proxy omits index fieldck for streaming tool_call args lost when proxy omits inde…#6662
jameswangchen wants to merge 1 commit intoAstrBotDevs:masterfrom
jameswangchen:fix/gemini-streaming-toolcall

Conversation

@jameswangchen
Copy link

@jameswangchen jameswangchen commented Mar 20, 2026

Fixes #6661

Motivation

When using Gemini models through OpenAI-compatible proxy services, streaming tool_call chunks are missing the required
index field. The openai SDK's ChatCompletionStreamState.handle_chunk() rejects these chunks, silently dropping
tool_call arguments. This causes AttributeError: 'NoneType' object has no attribute 'items' crashes in tool
execution.

Modifications / 改动点

Files modified:

  • astrbot/core/provider/sources/openai_source.py

    • Add _StreamToolCallFallback class: manually accumulates tool_call data from rejected stream chunks and restores
      args after parsing
    • Add try/except around json.loads in _parse_openai_completion for robustness
  • astrbot/core/agent/runners/tool_loop_agent_runner.py

    • Add None guard for func_tool_args to prevent crash
  • This is NOT a breaking change. / 这不是一个破坏性变更。

Screenshots or Test Results / 运行截图或测试结果

Before fix: tool_call args are None, crashes with AttributeError
[WARN] Saving chunk state error: Expected list delta entry to have an index key
[INFO] 使用工具:memory_save,参数:None
AttributeError: 'NoneType' object has no attribute 'items'

After fix: fallback restores args from rejected chunks
[INFO] Stream fallback: restored args for memory_save
[INFO] 使用工具:memory_save,参数:{'content': '用户喜欢吃火锅', 'tags': 'preference,food'}

Zero impact on standard OpenAI/DeepSeek providers — fallback only activates when handle_chunk() throws.

Checklist / 检查清单

Summary by Sourcery

Add a streaming tool call fallback for OpenAI-compatible proxies that omit tool_call indices and harden tool-call argument handling to prevent crashes.

Bug Fixes:

  • Recover and reconstruct tool_call arguments from rejected streaming chunks when proxies omit the required index field.
  • Prevent crashes in tool execution when parsed tool_call arguments are missing or invalid by defaulting to empty argument objects.

Enhancements:

  • Improve robustness of JSON parsing for tool_call arguments in OpenAI completion parsing by safely handling malformed payloads.

…x field

Some OpenAI-compatible proxies (e.g. Gemini via continue proxy) return
streaming tool_call chunks without the required 'index' field, causing
the openai SDK's ChatCompletionStreamState to reject them with:
  "Expected list delta entry to have an 'index' key"

This results in tool_call arguments being None after stream completion,
leading to AttributeError crashes in tool execution.

Changes:
- Add _StreamToolCallFallback class that manually accumulates tool_call
  data from rejected stream chunks and restores args after parsing
- Add None guard for func_tool_args in tool_loop_agent_runner
- Add try/except around json.loads in _parse_openai_completion for
  robustness against malformed arguments strings

Fixes tool_call failures when using Gemini models through OpenAI-
compatible proxy services with streaming enabled.
@auto-assign auto-assign bot requested review from Fridemn and LIghtJUNction March 20, 2026 03:39
@dosubot dosubot bot added the size:M This PR changes 30-99 lines, ignoring generated files. label Mar 20, 2026
@gemini-code-assist
Copy link
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request addresses a critical issue where streaming tool call arguments are lost when using OpenAI-compatible proxy services that omit the index field in their responses. The changes introduce a robust fallback mechanism to reconstruct these missing arguments and enhance the overall stability of tool execution by preventing crashes related to null or malformed argument data.

Highlights

  • Streaming Tool Call Argument Fallback: Implemented a _StreamToolCallFallback class to manually accumulate tool_call data from streaming chunks that are rejected by the OpenAI SDK due to missing index fields, particularly from OpenAI-compatible proxy services like Gemini.
  • Robust JSON Parsing: Added try/except blocks around json.loads calls when parsing tool call arguments to gracefully handle JSONDecodeError or TypeError, preventing crashes when arguments are malformed or unexpected.
  • Null Argument Handling: Introduced a None guard for func_tool_args in the tool loop agent runner to ensure that tool function arguments are always a dictionary, preventing AttributeError when arguments are unexpectedly None.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@dosubot dosubot bot added the area:provider The bug / feature is about AI Provider, Models, LLM Agent, LLM Agent Runner. label Mar 20, 2026
Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues, and left some high level feedback:

  • The _StreamToolCallFallback.collect_from_chunk helper only looks at chunk.choices[0], so any multi-choice streaming responses will lose fallback data for non-zero choices; consider iterating over all chunk.choices instead.
  • In _StreamToolCallFallback.apply_to, you unconditionally set response.role = "tool" when rebuilding from fallback; verify whether the expected role for messages containing tool_calls should remain "assistant", as switching the role may break downstream assumptions about assistant vs tool messages.
  • The logger.info calls in the fallback path (e.g. "Stream fallback: restored args...") may be too noisy for normal operation; consider downgrading to debug or gating them behind a verbosity flag while still keeping the warning when handle_chunk fails.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The `_StreamToolCallFallback.collect_from_chunk` helper only looks at `chunk.choices[0]`, so any multi-choice streaming responses will lose fallback data for non-zero choices; consider iterating over all `chunk.choices` instead.
- In `_StreamToolCallFallback.apply_to`, you unconditionally set `response.role = "tool"` when rebuilding from fallback; verify whether the expected role for messages containing `tool_calls` should remain `"assistant"`, as switching the role may break downstream assumptions about assistant vs tool messages.
- The `logger.info` calls in the fallback path (e.g. "Stream fallback: restored args...") may be too noisy for normal operation; consider downgrading to `debug` or gating them behind a verbosity flag while still keeping the warning when `handle_chunk` fails.

## Individual Comments

### Comment 1
<location path="astrbot/core/provider/sources/openai_source.py" line_range="56" />
<code_context>
+        if not delta or not hasattr(delta, "tool_calls") or not delta.tool_calls:
+            return
+        for tc in delta.tool_calls:
+            idx = getattr(tc, "index", None) or 0
+            if idx not in self._calls:
+                self._calls[idx] = {"id": "", "name": "", "arguments": ""}
</code_context>
<issue_to_address>
**issue (bug_risk):** Potential collision when multiple tool_calls share a falsy index value

`getattr(tc, "index", None) or 0` collapses missing indices and an explicit `index=0` into the same key. If multiple tool calls share `index=0` (or some are missing and some are `0`), their state will be merged and reconstruction will be wrong. Consider preserving `tc.index` when present and using a distinct fallback (e.g., a separate counter or sentinel key) only when the index is actually `None`.
</issue_to_address>

### Comment 2
<location path="astrbot/core/provider/sources/openai_source.py" line_range="97" />
<code_context>
+            response.tools_call_args.append(parsed)
+            response.tools_call_name.append(fb["name"])
+            response.tools_call_ids.append(fb["id"] or f"fallback_{idx}")
+            response.role = "tool"
+            logger.info(f"Stream fallback: rebuilt tool_call {fb['name']}({parsed})")
+
</code_context>
<issue_to_address>
**issue (bug_risk):** Rewriting the final response role to "tool" may be semantically incorrect

This fallback path sets `response.role = "tool"` even though no actual tool result is present. Downstream code usually expects the *requesting* message to be `"assistant"` and only actual tool outputs to be `"tool"`, so this change may break logic that branches on role. Consider keeping the original role here, or only switching to `"tool"` if you’ve confirmed all downstream consumers expect the triggering message to be marked that way.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

if not delta or not hasattr(delta, "tool_calls") or not delta.tool_calls:
return
for tc in delta.tool_calls:
idx = getattr(tc, "index", None) or 0
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Potential collision when multiple tool_calls share a falsy index value

getattr(tc, "index", None) or 0 collapses missing indices and an explicit index=0 into the same key. If multiple tool calls share index=0 (or some are missing and some are 0), their state will be merged and reconstruction will be wrong. Consider preserving tc.index when present and using a distinct fallback (e.g., a separate counter or sentinel key) only when the index is actually None.

response.tools_call_args.append(parsed)
response.tools_call_name.append(fb["name"])
response.tools_call_ids.append(fb["id"] or f"fallback_{idx}")
response.role = "tool"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Rewriting the final response role to "tool" may be semantically incorrect

This fallback path sets response.role = "tool" even though no actual tool result is present. Downstream code usually expects the requesting message to be "assistant" and only actual tool outputs to be "tool", so this change may break logic that branches on role. Consider keeping the original role here, or only switching to "tool" if you’ve confirmed all downstream consumers expect the triggering message to be marked that way.

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a robust fallback mechanism to handle malformed streaming tool call chunks from non-compliant OpenAI-compatible proxies, which can omit the index field. The core of the fix is the new _StreamToolCallFallback class that manually reconstructs tool call data from rejected stream chunks. The changes also include safeguards in the agent runner and argument parsing logic to prevent crashes. The approach is solid and directly addresses the issue. I've included a couple of minor suggestions to improve code clarity and consistency.

Comment on lines +91 to +96
response.tools_call_args = response.tools_call_args or []
response.tools_call_name = response.tools_call_name or []
response.tools_call_ids = response.tools_call_ids or []
response.tools_call_args.append(parsed)
response.tools_call_name.append(fb["name"])
response.tools_call_ids.append(fb["id"] or f"fallback_{idx}")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The LLMResponse dataclass initializes tools_call_args, tools_call_name, and tools_call_ids with default_factory=list, meaning they will be empty lists by default, not None. The assignments using or [] are therefore redundant and can be removed for cleaner code.

Suggested change
response.tools_call_args = response.tools_call_args or []
response.tools_call_name = response.tools_call_name or []
response.tools_call_ids = response.tools_call_ids or []
response.tools_call_args.append(parsed)
response.tools_call_name.append(fb["name"])
response.tools_call_ids.append(fb["id"] or f"fallback_{idx}")
response.tools_call_args.append(parsed)
response.tools_call_name.append(fb["name"])
response.tools_call_ids.append(fb["id"] or f"fallback_{idx}")

args = json.loads(tool_call.function.arguments)
try:
args = json.loads(tool_call.function.arguments)
except (json.JSONDecodeError, TypeError):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

For consistency with the new _StreamToolCallFallback._parse_args method, consider also catching ValueError here. This can improve robustness as json.loads can raise ValueError or its subclasses for various parsing issues.

Suggested change
except (json.JSONDecodeError, TypeError):
except (json.JSONDecodeError, TypeError, ValueError):

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

Labels

area:provider The bug / feature is about AI Provider, Models, LLM Agent, LLM Agent Runner. size:M This PR changes 30-99 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Streaming tool_call arguments lost when OpenAI-compatible proxy omits index field (e.g. Gemini)

1 participant