Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions astrbot/core/provider/sources/gemini_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -439,7 +439,8 @@ def append_or_extend(
elif role == "assistant":
parts = []
if isinstance(content, str):
parts.append(types.Part.from_text(text=content))
if content:
parts.append(types.Part.from_text(text=content))
elif isinstance(content, list):
thinking_signature = None
text = ""
Expand All @@ -460,7 +461,13 @@ def append_or_extend(
)
thinking_signature = None

if (
if not text and not thinking_signature:
# Nothing to send, e.g. a turn from another provider that only
# kept an unsigned think block. An empty text part has no payload;
# relays that drop empty strings forward it as a Part without data,
# which Gemini rejects.
pass
elif (
not text
and thinking_signature
and "tool_calls" in message
Expand Down
74 changes: 74 additions & 0 deletions tests/test_gemini_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,80 @@ async def test_gemini_prepare_conversation_preserves_user_model_history():
assert contents[-1].parts[-1].text == "assistant turn"


@pytest.mark.asyncio
async def test_gemini_prepare_conversation_skips_unsigned_empty_assistant_text():
provider = ProviderGoogleGenAI.__new__(ProviderGoogleGenAI)

contents = await provider._prepare_conversation(
{
"messages": [
{"role": "user", "content": "search the weather"},
{
"role": "assistant",
"content": [{"type": "think", "think": "Let me search."}],
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {
"name": "web_search",
"arguments": '{"q": "weather"}',
},
}
],
},
{
"role": "tool",
"tool_call_id": "call_1",
"name": "web_search",
"content": "sunny",
},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_2",
"type": "function",
"function": {"name": "web_search", "arguments": "{}"},
}
],
},
]
}
)

model_parts = [
part
for content in contents
if isinstance(content, types.ModelContent)
for part in content.parts or []
]
assert [part.function_call.name for part in model_parts] == [
"web_search",
"web_search",
]
assert all(part.text is None for part in model_parts)


@pytest.mark.asyncio
async def test_gemini_prepare_conversation_keeps_placeholder_for_empty_assistant():
provider = ProviderGoogleGenAI.__new__(ProviderGoogleGenAI)

contents = await provider._prepare_conversation(
{
"messages": [
{"role": "user", "content": "user turn"},
{"role": "assistant", "content": ""},
{"role": "user", "content": "current user turn"},
]
}
)

assert contents[1].parts is not None
assert [part.text for part in contents[1].parts] == [" "]


@pytest.mark.asyncio
async def test_gemini_prepare_conversation_resolves_local_history_image(tmp_path):
image_path = tmp_path / "history.webp"
Expand Down