From 0923e9d54aee5094afc31c774737b76b947268f8 Mon Sep 17 00:00:00 2001 From: Clhikari Date: Thu, 19 Mar 2026 21:15:39 +0800 Subject: [PATCH 1/3] fix: retry gemini empty parts response --- .../core/provider/sources/gemini_source.py | 26 +++++- tests/test_gemini_source.py | 80 +++++++++++++++++++ 2 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 tests/test_gemini_source.py diff --git a/astrbot/core/provider/sources/gemini_source.py b/astrbot/core/provider/sources/gemini_source.py index 9557f3dbcd..851ffb9c12 100644 --- a/astrbot/core/provider/sources/gemini_source.py +++ b/astrbot/core/provider/sources/gemini_source.py @@ -528,6 +528,8 @@ async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse: conversation = self._prepare_conversation(payloads) temperature = payloads.get("temperature", 0.7) + empty_parts_retry_count = 0 + max_empty_parts_retries = 3 result: types.GenerateContentResponse | None = None while True: @@ -550,7 +552,9 @@ async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse: logger.error(f"请求失败, 返回的 candidates 为空: {result}") raise Exception("请求失败, 返回的 candidates 为空。") - if result.candidates[0].finish_reason == types.FinishReason.RECITATION: + candidate = result.candidates[0] + + if candidate.finish_reason == types.FinishReason.RECITATION: if temperature > 2: raise Exception("温度参数已超过最大值2,仍然发生recitation") temperature += 0.2 @@ -559,6 +563,26 @@ async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse: ) continue + if ( + candidate.finish_reason == types.FinishReason.STOP + and candidate.content + and not candidate.content.parts + ): + if empty_parts_retry_count < max_empty_parts_retries: + empty_parts_retry_count += 1 + logger.warning( + "Gemini 返回 STOP 但 candidate.content.parts 为空,正在重试(%s/%s): %s", + empty_parts_retry_count, + max_empty_parts_retries, + candidate, + ) + await asyncio.sleep(0.2) + continue + logger.warning( + "Gemini 在 %s 次重试后仍返回空的 candidate.content.parts,将沿用现有失败逻辑。", + max_empty_parts_retries, + ) + break except APIError as e: diff --git a/tests/test_gemini_source.py b/tests/test_gemini_source.py new file mode 100644 index 0000000000..d6bbb2615b --- /dev/null +++ b/tests/test_gemini_source.py @@ -0,0 +1,80 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from google.genai import types + +from astrbot.core.provider.sources.gemini_source import ProviderGoogleGenAI + + +def _make_provider(overrides: dict | None = None) -> ProviderGoogleGenAI: + provider_config = { + "id": "test-gemini", + "type": "googlegenai_chat_completion", + "model": "gemini-3-flash-preview", + "key": ["test-key"], + } + if overrides: + provider_config.update(overrides) + return ProviderGoogleGenAI( + provider_config=provider_config, + provider_settings={}, + ) + + +def _make_result(parts: list | None, response_id: str) -> SimpleNamespace: + return SimpleNamespace( + candidates=[ + SimpleNamespace( + content=SimpleNamespace(parts=parts), + finish_reason=types.FinishReason.STOP, + ) + ], + response_id=response_id, + usage_metadata=None, + ) + + +@pytest.mark.asyncio +async def test_gemini_query_retries_empty_parts_response(monkeypatch: pytest.MonkeyPatch): + provider = _make_provider() + try: + sleep_mock = AsyncMock() + monkeypatch.setattr( + "astrbot.core.provider.sources.gemini_source.asyncio.sleep", + sleep_mock, + ) + + empty_result = _make_result([], "empty-response") + success_result = _make_result( + [ + SimpleNamespace( + text="Recovered response", + thought=False, + function_call=None, + inline_data=None, + thought_signature=None, + ) + ], + "success-response", + ) + generate_content = AsyncMock(side_effect=[empty_result, success_result]) + provider.client = SimpleNamespace( + models=SimpleNamespace(generate_content=generate_content), + aclose=AsyncMock(), + ) + + response = await provider._query( + payloads={ + "messages": [{"role": "user", "content": "hello"}], + "model": "gemini-3-flash-preview", + }, + tools=None, + ) + + assert generate_content.await_count == 2 + sleep_mock.assert_awaited_once() + assert response.completion_text == "Recovered response" + assert response.id == "success-response" + finally: + await provider.terminate() From fbdcf50ec8176c67df949656148707f7a32185e1 Mon Sep 17 00:00:00 2001 From: Clhikari Date: Sat, 21 Mar 2026 11:41:52 +0800 Subject: [PATCH 2/3] test: cover gemini empty-parts retry tool-call recovery --- tests/test_gemini_source.py | 91 +++++++++++++++++++++++++++++++++---- 1 file changed, 81 insertions(+), 10 deletions(-) diff --git a/tests/test_gemini_source.py b/tests/test_gemini_source.py index d6bbb2615b..2af59c3bef 100644 --- a/tests/test_gemini_source.py +++ b/tests/test_gemini_source.py @@ -35,8 +35,39 @@ def _make_result(parts: list | None, response_id: str) -> SimpleNamespace: ) +def _make_text_part(text: str) -> SimpleNamespace: + return SimpleNamespace( + text=text, + thought=False, + function_call=None, + inline_data=None, + thought_signature=None, + ) + + +def _make_function_call_part( + name: str, + args: dict, + *, + tool_call_id: str | None = None, +) -> SimpleNamespace: + return SimpleNamespace( + text=None, + thought=False, + function_call=SimpleNamespace( + name=name, + args=args, + id=tool_call_id, + ), + inline_data=None, + thought_signature=None, + ) + + @pytest.mark.asyncio -async def test_gemini_query_retries_empty_parts_response(monkeypatch: pytest.MonkeyPatch): +async def test_gemini_query_retries_empty_parts_response( + monkeypatch: pytest.MonkeyPatch, +): provider = _make_provider() try: sleep_mock = AsyncMock() @@ -47,15 +78,7 @@ async def test_gemini_query_retries_empty_parts_response(monkeypatch: pytest.Mon empty_result = _make_result([], "empty-response") success_result = _make_result( - [ - SimpleNamespace( - text="Recovered response", - thought=False, - function_call=None, - inline_data=None, - thought_signature=None, - ) - ], + [_make_text_part("Recovered response")], "success-response", ) generate_content = AsyncMock(side_effect=[empty_result, success_result]) @@ -78,3 +101,51 @@ async def test_gemini_query_retries_empty_parts_response(monkeypatch: pytest.Mon assert response.id == "success-response" finally: await provider.terminate() + + +@pytest.mark.asyncio +async def test_gemini_query_retries_empty_parts_before_function_call( + monkeypatch: pytest.MonkeyPatch, +): + provider = _make_provider() + try: + sleep_mock = AsyncMock() + monkeypatch.setattr( + "astrbot.core.provider.sources.gemini_source.asyncio.sleep", + sleep_mock, + ) + + empty_result = _make_result([], "empty-response") + tool_call_result = _make_result( + [ + _make_function_call_part( + "read_file", + {"path": "README.md"}, + tool_call_id="call-readme", + ) + ], + "tool-call-response", + ) + generate_content = AsyncMock(side_effect=[empty_result, tool_call_result]) + provider.client = SimpleNamespace( + models=SimpleNamespace(generate_content=generate_content), + aclose=AsyncMock(), + ) + + response = await provider._query( + payloads={ + "messages": [{"role": "user", "content": "summarize the file"}], + "model": "gemini-3-flash-preview", + }, + tools=None, + ) + + assert generate_content.await_count == 2 + sleep_mock.assert_awaited_once() + assert response.role == "tool" + assert response.tools_call_name == ["read_file"] + assert response.tools_call_args == [{"path": "README.md"}] + assert response.tools_call_ids == ["call-readme"] + assert response.id == "tool-call-response" + finally: + await provider.terminate() From 70e45543e2ef6d74e8e75cb53d67c1585cb2640a Mon Sep 17 00:00:00 2001 From: Clhikari Date: Mon, 23 Mar 2026 13:20:24 +0800 Subject: [PATCH 3/3] refactor: clarify local gemini empty-parts retry constants --- astrbot/core/provider/sources/gemini_source.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/astrbot/core/provider/sources/gemini_source.py b/astrbot/core/provider/sources/gemini_source.py index 851ffb9c12..186ba81148 100644 --- a/astrbot/core/provider/sources/gemini_source.py +++ b/astrbot/core/provider/sources/gemini_source.py @@ -528,8 +528,10 @@ async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse: conversation = self._prepare_conversation(payloads) temperature = payloads.get("temperature", 0.7) + # Keep the retry knobs local to this narrow Gemini workaround. + EMPTY_PARTS_RETRY_DELAY_SECONDS = 0.2 + MAX_EMPTY_PARTS_RETRIES = 3 empty_parts_retry_count = 0 - max_empty_parts_retries = 3 result: types.GenerateContentResponse | None = None while True: @@ -568,19 +570,19 @@ async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse: and candidate.content and not candidate.content.parts ): - if empty_parts_retry_count < max_empty_parts_retries: + if empty_parts_retry_count < MAX_EMPTY_PARTS_RETRIES: empty_parts_retry_count += 1 logger.warning( "Gemini 返回 STOP 但 candidate.content.parts 为空,正在重试(%s/%s): %s", empty_parts_retry_count, - max_empty_parts_retries, + MAX_EMPTY_PARTS_RETRIES, candidate, ) - await asyncio.sleep(0.2) + await asyncio.sleep(EMPTY_PARTS_RETRY_DELAY_SECONDS) continue logger.warning( "Gemini 在 %s 次重试后仍返回空的 candidate.content.parts,将沿用现有失败逻辑。", - max_empty_parts_retries, + MAX_EMPTY_PARTS_RETRIES, ) break