Skip to content

fix: handle delta=None chunks in streaming to prevent SDK to_dict() error#8244

Open
x1051445024 wants to merge 4 commits into
AstrBotDevs:masterfrom
x1051445024:fix/stream-state-delta-none
Open

fix: handle delta=None chunks in streaming to prevent SDK to_dict() error#8244
x1051445024 wants to merge 4 commits into
AstrBotDevs:masterfrom
x1051445024:fix/stream-state-delta-none

Conversation

@x1051445024
Copy link
Copy Markdown

@x1051445024 x1051445024 commented May 19, 2026

问题描述

当使用 Gemini、DeepSeek 或部分 OpenAI 兼容中转接口时,流式响应中出现以下错误:

Saving chunk state error: 'NoneType' object has no attribute 'to_dict'

根因分析

错误发生在 openai_source.py_query_stream 方法中。当某些提供商返回的 ChatCompletionChunkchoice.deltaNone 时(例如 ContentBlockDeltaEvent 等不带 delta 的 chunk),ChatCompletionStreamState._convert_initial_chunk_into_snapshot 内部第 747 行调用 choice.delta.to_dict() 抛出异常。

相关讨论:

  • openai-python#5069
  • openai-python#5047

修复方案

改动 1:在调用 state.handle_chunk() 前检查 delta 是否为 None,跳过无贡献的 chunk。

改动 2:将 get_final_completion() 包裹在 try/except 中,失败时构造空的 ChatCompletion 作为降级方案。此时内容已通过流式 yield 发出,不会丢失。

影响范围

  • 不删除 ChatCompletionStreamState 导入(保留 SDK 累积逻辑)
  • 不改变 state 初始化(避免 NameError)
  • 仅 2 处精准修改,diff 极小

测试环境

  • AstrBot 版本:v4.25.1
  • 测试提供商:Gemini、DeepSeek、OpenAI 兼容中转接口

Summary by Sourcery

Handle edge cases in OpenAI-compatible streaming responses to avoid SDK errors while preserving streamed output.

Bug Fixes:

  • Skip streaming chunks with delta=None to prevent SDK ChatCompletionStreamState errors when converting initial chunks into snapshots.
  • Wrap retrieval of the final streamed completion in a fallback that constructs an empty ChatCompletion if state.get_final_completion() fails, ensuring the request still completes.
  • Normalize tool-call arguments by replacing None with an empty object to avoid errors when tools are returned without parameters.

…rror

When certain OpenAI-compatible providers (Gemini, DeepSeek, some proxies)
return chunks with choice.delta=None (e.g. ContentBlockDeltaEvent),
ChatCompletionStreamState._convert_initial_chunk_into_snapshot internally
calls choice.delta.to_dict() at line 747, causing:
  'NoneType' object has no attribute 'to_dict'

Fix:
  1. Skip handle_chunk when delta is None (delta=None chunks have no
     content contribution anyway)
  2. Wrap get_final_completion in try/except to gracefully fall back to
     empty ChatCompletion if SDK state is corrupted

Refs: openai-python#5069, openai-python#5047
@dosubot dosubot Bot added size:S This PR changes 10-29 lines, ignoring generated files. area:provider The bug / feature is about AI Provider, Models, LLM Agent, LLM Agent Runner. labels May 19, 2026
Copy link
Copy Markdown
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 1 issue, and left some high level feedback:

  • The new if delta is not None: block leaves the original logger.error("Saving chunk state error: " + str(e)) outside the try/except, which will both double-log on exceptions and raise a NameError when no exception occurs or when delta is None; that outer log should either be removed or moved inside the except.
  • Consider moving from openai.types.chat.chat_completion import ChatCompletion to the module-level imports instead of importing inside the except block in _query_stream to avoid repeated imports and keep dependencies visible at the top of the file.
  • When constructing the fallback empty ChatCompletion, it may be more robust to populate fields like id, model, and possibly a minimal choices structure from available stream context rather than hardcoding empty values, to reduce the chance of downstream consumers failing on unexpected empty metadata.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The new `if delta is not None:` block leaves the original `logger.error("Saving chunk state error: " + str(e))` outside the `try/except`, which will both double-log on exceptions and raise a `NameError` when no exception occurs or when `delta` is `None`; that outer log should either be removed or moved inside the `except`.
- Consider moving `from openai.types.chat.chat_completion import ChatCompletion` to the module-level imports instead of importing inside the `except` block in `_query_stream` to avoid repeated imports and keep dependencies visible at the top of the file.
- When constructing the fallback empty `ChatCompletion`, it may be more robust to populate fields like `id`, `model`, and possibly a minimal `choices` structure from available stream context rather than hardcoding empty values, to reduce the chance of downstream consumers failing on unexpected empty metadata.

## Individual Comments

### Comment 1
<location path="astrbot/core/provider/sources/openai_source.py" line_range="679" />
<code_context>
+                try:
+                    state.handle_chunk(chunk)
+                except Exception as e:
+                    logger.error("Saving chunk state error: " + str(e))
                 logger.error("Saving chunk state error: " + str(e))
             # logger.debug(f"chunk delta: {delta}")
</code_context>
<issue_to_address>
**issue (bug_risk):** Exception variable `e` is used outside its scope and the error is logged twice.

This will run on every `delta is not None` chunk, even when no exception occurs, and will raise `NameError` when `e` is undefined. The logging that uses `e` should stay inside the `except` block, or the outer log should use a message that doesn’t reference `e`.
</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.

Comment thread astrbot/core/provider/sources/openai_source.py
Copy link
Copy Markdown
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 stability improvements to the OpenAI provider by handling null stream chunks and tool call arguments to prevent crashes. It also adds error handling around the final completion retrieval. Review feedback identified a duplicate logging statement that would trigger on every valid chunk, a redundant import, and a flaw in the fallback logic for failed completions which would still lead to downstream errors. A more robust early return was suggested for the latter.

state.handle_chunk(chunk)
except Exception as e:
logger.error("Saving chunk state error: " + str(e))
logger.error("Saving chunk state error: " + str(e))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

这里似乎有一个重复的错误日志记录。logger.errorexcept 块内部(第 679 行)已经调用过一次,此处的再次调用(第 680 行)会在每次 delta 不为 None 时都执行,而不仅仅是在发生异常时。这会导致不必要的和误导性的错误日志。建议删除此行。

Comment on lines 708 to 723
try:
final_completion = state.get_final_completion()
except Exception as e:
logger.error("get_final_completion error: " + str(e))
# fallback: 构造空 ChatCompletion(内容已通过流式 yield 发出)
from openai.types.chat.chat_completion import ChatCompletion
final_completion = ChatCompletion(
id='',
choices=[],
created=0,
model='',
object='chat.completion',
)
llm_response = await self._parse_openai_completion(final_completion, tools)

yield llm_response
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

当前的降级方案在 get_final_completion() 失败时,会创建一个空的 ChatCompletion 对象。然而,这个空对象(特别是 choices=[])会导致后续调用的 _parse_openai_completion 方法在第 865 行因找不到 choices 而抛出 EmptyModelOutputError 异常,这使得降级方案实际上无法正常工作。

考虑到流式响应的内容大部分已经通过 yield 发出,一个更简洁且健壮的处理方式可能是在捕获异常后记录错误并直接结束生成器,而不是尝试构造一个会引发新问题的降级对象。

        try:
            final_completion = state.get_final_completion()
            llm_response = await self._parse_openai_completion(final_completion, tools)
            yield llm_response
        except Exception as e:
            logger.error("get_final_completion error: " + str(e))
            # 内容已通过流式 yield 发出,记录错误并正常结束即可。
            return

except Exception as e:
logger.error("get_final_completion error: " + str(e))
# fallback: 构造空 ChatCompletion(内容已通过流式 yield 发出)
from openai.types.chat.chat_completion import ChatCompletion
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

ChatCompletion 已经在文件顶部(第 19 行)导入,此处的局部导入是多余的。为了保持代码整洁和一致性,建议移除此行。

- Remove orphan logger.error that caused NameError on every chunk
- Replace broken empty ChatCompletion fallback with clean return;
  streamed content already yielded, no data loss

Co-authored-by: sourcery-ai[bot] <sourcery-ai[bot]@users.noreply.github.com>
Co-authored-by: gemini-code-assist[bot] <gemini-code-assist[bot]@users.noreply.github.com>
@dosubot dosubot Bot added size:M This PR changes 30-99 lines, ignoring generated files. and removed size:S This PR changes 10-29 lines, ignoring generated files. labels May 19, 2026
Previous fix_pr_v3 wrongly injected code into terminate() instead.
Now correctly:
1. Replace empty ChatCompletion fallback with clean return in _query_stream
2. Revert terminate() to original (await self.client.close() only)
Previous fix_pr_v3 injected wrong-indentation code into terminate().
@dosubot dosubot Bot added size:S This PR changes 10-29 lines, ignoring generated files. and removed size:M This PR changes 30-99 lines, ignoring generated files. labels May 19, 2026
@x1051445024
Copy link
Copy Markdown
Author

✅ 所有 Review 反馈已修复

感谢 Sourcerygemini-code-assist 的评审。后续 commits 已全部处理:

Commit 修复内容 对应反馈
bc6ba58 删除重复的 logger.error(移出 try/except 作用域) Sourcery + Gemini 指出
69cd045b 空 ChatCompletion 降级 → 干净 return,避免 EmptyModelOutputError Gemini 指出
ac6314f0 terminate() 回滚为原始 await self.client.close() 修复 review 引入的缩进错误

当前差异:+24 / −12 行。

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:S This PR changes 10-29 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant