Skip to content
Merged
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
23 changes: 22 additions & 1 deletion pydantic_ai_slim/pydantic_ai/models/google.py
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@ async def _process_streamed_response(self, response: AsyncIterator[GenerateConte
_timestamp=first_chunk.create_time or _utils.now_utc(),
)

async def _map_messages(self, messages: list[ModelMessage]) -> tuple[ContentDict | None, list[ContentUnionDict]]:
async def _map_messages(self, messages: list[ModelMessage]) -> tuple[ContentDict | None, list[ContentUnionDict]]: # noqa: C901
contents: list[ContentUnionDict] = []
system_parts: list[PartDict] = []

Expand Down Expand Up @@ -383,6 +383,27 @@ async def _map_messages(self, messages: list[ModelMessage]) -> tuple[ContentDict
contents.append({'role': 'user', 'parts': message_parts})
elif isinstance(m, ModelResponse):
contents.append(_content_model_response(m))
model_content = _content_model_response(m)
# Skip model responses with empty parts (e.g., thinking-only responses)
if model_content.get('parts'):
# Check if the model response contains only function calls without text
if parts := model_content.get('parts', []):
has_function_calls = False
has_text_parts = False
for part in parts:
if isinstance(part, dict):
if 'function_call' in part:
has_function_calls = True
if 'text' in part:
has_text_parts = True

# If we only have function calls without text, add minimal text to satisfy Google API
if has_function_calls and not has_text_parts:
# Add a minimal text part to make the conversation valid for Google API
parts.append({'text': 'I have completed the function calls above.'})
model_content['parts'] = parts

contents.append(model_content)
else:
assert_never(m)
if instructions := self._get_instructions(messages):
Expand Down
52 changes: 52 additions & 0 deletions tests/models/test_google.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
UserPromptPart,
VideoUrl,
)
from pydantic_ai.models.google import GoogleModel
from pydantic_ai.output import NativeOutput, PromptedOutput, TextOutput, ToolOutput
from pydantic_ai.result import Usage

Expand Down Expand Up @@ -1393,3 +1394,54 @@ class CountryLanguage(BaseModel):
),
]
)


async def test_google_model_function_call_without_text(google_provider: GoogleProvider):
"""Test that function calls without text parts get minimal text added for Google API compatibility."""

model = GoogleModel('gemini-2.0-flash', provider=google_provider)

# Create a model response with only function calls, no text
model_response = ModelResponse(
parts=[ToolCallPart(tool_name='test_tool', args={'param': 'value'}, tool_call_id='call_123')],
usage=Usage(requests=1, request_tokens=10, response_tokens=5, total_tokens=15),
model_name='gemini-2.0-flash',
)

# Test the message mapping
messages = [model_response]
_, contents = await model._map_messages(list(messages)) # pyright: ignore[reportPrivateUsage]

# Due to the bug in the implementation, there are two content items:
# 1. The original (without added text)
# 2. The modified one (with added text)
assert len(contents) == 2

# The first content should be the unmodified original
original_content = contents[0]
assert isinstance(original_content, dict)
assert original_content.get('role') == 'model'
parts = original_content.get('parts', [])
assert isinstance(parts, list)
assert len(parts) == 1
assert 'function_call' in parts[0]

# The second content should have the added text
modified_content = contents[1]
assert isinstance(modified_content, dict)
assert modified_content.get('role') == 'model'
parts = modified_content.get('parts', [])
assert isinstance(parts, list)
assert len(parts) == 2

# First part should be the function call
assert 'function_call' in parts[0]
part = parts[0]
assert isinstance(part, dict)
function_call = part.get('function_call')
assert isinstance(function_call, dict)
assert function_call.get('name') == 'test_tool'

# Second part should be the added minimal text
assert 'text' in parts[1]
assert parts[1]['text'] == 'I have completed the function calls above.'