fix: handle delta=None chunks in streaming to prevent SDK to_dict() error#8244
fix: handle delta=None chunks in streaming to prevent SDK to_dict() error#8244x1051445024 wants to merge 4 commits into
Conversation
…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
There was a problem hiding this comment.
Hey - I've found 1 issue, and left some high level feedback:
- The new
if delta is not None:block leaves the originallogger.error("Saving chunk state error: " + str(e))outside thetry/except, which will both double-log on exceptions and raise aNameErrorwhen no exception occurs or whendeltaisNone; that outer log should either be removed or moved inside theexcept. - Consider moving
from openai.types.chat.chat_completion import ChatCompletionto the module-level imports instead of importing inside theexceptblock in_query_streamto 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 likeid,model, and possibly a minimalchoicesstructure 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>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
There was a problem hiding this comment.
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)) |
| 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 |
There was a problem hiding this comment.
当前的降级方案在 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 |
- 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>
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().
✅ 所有 Review 反馈已修复感谢 Sourcery 和 gemini-code-assist 的评审。后续 commits 已全部处理:
当前差异:+24 / −12 行。 |
问题描述
当使用 Gemini、DeepSeek 或部分 OpenAI 兼容中转接口时,流式响应中出现以下错误:
根因分析
错误发生在
openai_source.py的_query_stream方法中。当某些提供商返回的ChatCompletionChunk中choice.delta为None时(例如ContentBlockDeltaEvent等不带 delta 的 chunk),ChatCompletionStreamState._convert_initial_chunk_into_snapshot内部第 747 行调用choice.delta.to_dict()抛出异常。相关讨论:
修复方案
改动 1:在调用
state.handle_chunk()前检查delta是否为None,跳过无贡献的 chunk。改动 2:将
get_final_completion()包裹在 try/except 中,失败时构造空的ChatCompletion作为降级方案。此时内容已通过流式yield发出,不会丢失。影响范围
ChatCompletionStreamState导入(保留 SDK 累积逻辑)state初始化(避免 NameError)测试环境
Summary by Sourcery
Handle edge cases in OpenAI-compatible streaming responses to avoid SDK errors while preserving streamed output.
Bug Fixes: