Skip to content

Commit a4c5c84

Browse files
fix: filter empty-text parts in GoogleGenAiChatModel to prevent API errors
Fixes #4556 When the Google GenAI API returns a candidate whose parts contain no text (e.g. thought-signature-only or server-side tool-invocation parts), the response converter was producing AssistantMessages with empty content. When these empty messages were stored in chat memory and sent in subsequent requests, the Google API rejected them with 'missing parts field' errors, breaking multi-turn conversations with tool calling. Two fixes applied: 1. responseCandidateToGeneration: add filter on part.text() presence so only parts with actual text content produce generations. Uses part.text().get() after the filter guard (no orElse needed). 2. toGeminiContent: filter out Content items whose parts list is empty before building the API request, so a stale empty-content AssistantMessage in chat memory cannot cause a 400 error. Also adds two unit tests in GoogleGenAiChatModelExtendedUsageTests covering both the empty-part candidate response and the clean text-part round-trip, so regressions are caught without a live API key. Signed-off-by: anuragg-saxenaa <anuragg.saxenaa@gmail.com>
1 parent 01130c5 commit a4c5c84

File tree

2 files changed

+81
-3
lines changed

2 files changed

+81
-3
lines changed

models/spring-ai-google-genai/src/main/java/org/springframework/ai/google/genai/GoogleGenAiChatModel.java

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -728,19 +728,23 @@ protected List<Generation> responseCandidateToGeneration(Candidate candidate) {
728728
.orElse(List.of())
729729
.stream()
730730
.filter(part -> part.toolCall().isEmpty() && part.toolResponse().isEmpty())
731+
.filter(part -> StringUtils.hasText(part.text().orElse("")))
731732
.map(part -> {
732733
var partMessageMetadata = new HashMap<>(messageMetadata);
733734
partMessageMetadata.put("isThought", part.thought().orElse(false));
734735
return AssistantMessage.builder()
735-
.content(part.text().orElse(""))
736+
.content(part.text().get())
736737
.properties(partMessageMetadata)
737738
.build();
738739
})
739740
.map(assistantMessage -> new Generation(assistantMessage, chatGenerationMetadata))
740741
.toList();
741742

742-
// If all parts were server-side tool invocations, return a single generation
743-
// with empty text but with the server-side tool invocation metadata
743+
// If all parts were server-side tool invocations or had no text content,
744+
// return a single generation with the metadata (no empty text parts).
745+
// Empty text AssistantMessages must not be added to chat history because
746+
// the Google API rejects subsequent requests containing content with no parts.
747+
// See: https://github.com/spring-projects/spring-ai/issues/4556
744748
if (generations.isEmpty()) {
745749
AssistantMessage assistantMessage = AssistantMessage.builder()
746750
.content("")
@@ -1005,6 +1009,7 @@ private List<Content> toGeminiContent(List<Message> instructions) {
10051009
.role(toGeminiMessageType(message.getMessageType()).getValue())
10061010
.parts(messageToGeminiParts(message))
10071011
.build())
1012+
.filter(content -> content.parts().isPresent() && !content.parts().get().isEmpty())
10081013
.toList();
10091014

10101015
return contents;

models/spring-ai-google-genai/src/test/java/org/springframework/ai/google/genai/GoogleGenAiChatModelExtendedUsageTests.java

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,4 +441,77 @@ void testUsageWithNullMetadata() {
441441
assertThat(genAiUsage.getCachedContentTokenCount()).isNull();
442442
}
443443

444+
@Test
445+
void testResponseCandidateWithEmptyTextPartsProducesNoEmptyGenerations() {
446+
// Regression test for https://github.com/spring-projects/spring-ai/issues/4556
447+
// A candidate whose parts have no text (e.g. thought-signature-only parts) must not
448+
// produce an AssistantMessage with empty content, because the Google API rejects
449+
// subsequent requests that include Content with an empty parts list.
450+
451+
// Part with no text — simulates a thought-signature-only part returned by Gemini
452+
Part emptyTextPart = Part.builder().build(); // no .text(...)
453+
454+
Content responseContent = Content.builder().parts(emptyTextPart).build();
455+
456+
GenerateContentResponseUsageMetadata usageMetadata = GenerateContentResponseUsageMetadata.builder()
457+
.promptTokenCount(10)
458+
.candidatesTokenCount(0)
459+
.totalTokenCount(10)
460+
.build();
461+
462+
Candidate candidate = Candidate.builder().content(responseContent).index(0).build();
463+
464+
GenerateContentResponse mockResponse = GenerateContentResponse.builder()
465+
.candidates(List.of(candidate))
466+
.usageMetadata(usageMetadata)
467+
.modelVersion("gemini-2.0-flash")
468+
.build();
469+
470+
this.chatModel.setMockGenerateContentResponse(mockResponse);
471+
472+
UserMessage userMessage = new UserMessage("Hello");
473+
Prompt prompt = new Prompt(List.of(userMessage));
474+
ChatResponse response = this.chatModel.call(prompt);
475+
476+
// The response must have exactly one generation (the empty-content fallback),
477+
// and that generation must not contain null content.
478+
assertThat(response.getResults()).isNotNull();
479+
assertThat(response.getResults()).hasSize(1);
480+
assertThat(response.getResults().get(0).getOutput().getText()).isNotNull();
481+
}
482+
483+
@Test
484+
void testToGeminiContentFiltersOutEmptyPartContent() {
485+
// Regression test for https://github.com/spring-projects/spring-ai/issues/4556
486+
// toGeminiContent must not include Content items whose parts list is empty,
487+
// because the Google API rejects requests with empty-parts Content entries.
488+
489+
// Candidate with a real text part
490+
Part textPart = Part.builder().text("Hello from Gemini").build();
491+
Content responseContent = Content.builder().parts(textPart).build();
492+
493+
GenerateContentResponseUsageMetadata usageMetadata = GenerateContentResponseUsageMetadata.builder()
494+
.promptTokenCount(5)
495+
.candidatesTokenCount(4)
496+
.totalTokenCount(9)
497+
.build();
498+
499+
Candidate candidate = Candidate.builder().content(responseContent).index(0).build();
500+
501+
GenerateContentResponse mockResponse = GenerateContentResponse.builder()
502+
.candidates(List.of(candidate))
503+
.usageMetadata(usageMetadata)
504+
.modelVersion("gemini-2.0-flash")
505+
.build();
506+
507+
this.chatModel.setMockGenerateContentResponse(mockResponse);
508+
509+
UserMessage userMessage = new UserMessage("Hi");
510+
Prompt prompt = new Prompt(List.of(userMessage));
511+
ChatResponse response = this.chatModel.call(prompt);
512+
513+
assertThat(response.getResults()).hasSize(1);
514+
assertThat(response.getResults().get(0).getOutput().getText()).isEqualTo("Hello from Gemini");
515+
}
516+
444517
}

0 commit comments

Comments
 (0)