Skip to content

Commit 6942a69

Browse files
committed
feat(google-genai): Support thought signatures for Gemini 3 Pro function calling
Add thought signature support required by Gemini 3 Pro when using function calling with includeThoughts enabled. Thought signatures preserve reasoning context during the internal tool execution loop. Changes from original implementation: - Attach thought signatures to functionCall parts per Google specification (not to text parts or separate empty parts) - Clarify in documentation that validation applies only to the current turn's function calling loop, not to historical conversation messages - Add integration tests validating function calls with signatures Key behaviors: - Signatures are extracted from model responses and stored in message metadata - During internal tool execution, signatures are correctly attached to functionCall parts when sending back function responses - Handles both parallel and sequential function calls correctly: - Parallel: only first functionCall gets the signature (per API spec) - Sequential: each step's first functionCall gets its signature - Previous turn signatures in conversation history are not validated by the API See: https://ai.google.dev/gemini-api/docs/thought-signatures Signed-off-by: Dan Dobrin <[email protected]> Signed-off-by: Mark Pollack <[email protected]>
1 parent d4b6cf2 commit 6942a69

File tree

10 files changed

+776
-37
lines changed

10 files changed

+776
-37
lines changed

auto-configurations/models/spring-ai-autoconfigure-model-google-genai/src/test/java/org/springframework/ai/model/google/genai/autoconfigure/chat/GoogleGenAiPropertiesTests.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,25 @@ void extendedUsageMetadataDefaultBinding() {
131131
});
132132
}
133133

134+
@Test
135+
void includeThoughtsPropertiesBinding() {
136+
this.contextRunner.withPropertyValues("spring.ai.google.genai.chat.options.include-thoughts=true")
137+
.run(context -> {
138+
GoogleGenAiChatProperties chatProperties = context.getBean(GoogleGenAiChatProperties.class);
139+
assertThat(chatProperties.getOptions().getIncludeThoughts()).isTrue();
140+
});
141+
}
142+
143+
@Test
144+
void includeThoughtsDefaultBinding() {
145+
// Test that defaults are applied when not specified
146+
this.contextRunner.run(context -> {
147+
GoogleGenAiChatProperties chatProperties = context.getBean(GoogleGenAiChatProperties.class);
148+
// Should be null when not set
149+
assertThat(chatProperties.getOptions().getIncludeThoughts()).isNull();
150+
});
151+
}
152+
134153
@Configuration
135154
@EnableConfigurationProperties({ GoogleGenAiConnectionProperties.class, GoogleGenAiChatProperties.class,
136155
GoogleGenAiEmbeddingConnectionProperties.class })

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

Lines changed: 66 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -281,20 +281,48 @@ else if (message instanceof UserMessage userMessage) {
281281
}
282282
else if (message instanceof AssistantMessage assistantMessage) {
283283
List<Part> parts = new ArrayList<>();
284+
285+
// Check if there are thought signatures to restore.
286+
// Per Google's documentation, thought signatures must be attached to the
287+
// first functionCall part in each step of the current turn.
288+
// See: https://ai.google.dev/gemini-api/docs/thought-signatures
289+
List<byte[]> thoughtSignatures = null;
290+
if (assistantMessage.getMetadata() != null
291+
&& assistantMessage.getMetadata().containsKey("thoughtSignatures")) {
292+
Object signaturesObj = assistantMessage.getMetadata().get("thoughtSignatures");
293+
if (signaturesObj instanceof List) {
294+
thoughtSignatures = new ArrayList<>((List<byte[]>) signaturesObj);
295+
}
296+
}
297+
298+
// Add text part (without thought signature - signatures go on functionCall
299+
// parts)
284300
if (StringUtils.hasText(assistantMessage.getText())) {
285-
parts.add(Part.fromText(assistantMessage.getText()));
301+
parts.add(Part.builder().text(assistantMessage.getText()).build());
286302
}
303+
304+
// Add function call parts with thought signatures attached.
305+
// Per Google's docs: "The first functionCall part in each step of the
306+
// current turn must include its thought_signature."
287307
if (!CollectionUtils.isEmpty(assistantMessage.getToolCalls())) {
288-
parts.addAll(assistantMessage.getToolCalls()
289-
.stream()
290-
.map(toolCall -> Part.builder()
308+
List<AssistantMessage.ToolCall> toolCalls = assistantMessage.getToolCalls();
309+
for (int i = 0; i < toolCalls.size(); i++) {
310+
AssistantMessage.ToolCall toolCall = toolCalls.get(i);
311+
Part.Builder partBuilder = Part.builder()
291312
.functionCall(FunctionCall.builder()
292313
.name(toolCall.name())
293314
.args(parseJsonToMap(toolCall.arguments()))
294-
.build())
295-
.build())
296-
.toList());
315+
.build());
316+
317+
// Attach thought signature to function call part if available
318+
if (thoughtSignatures != null && !thoughtSignatures.isEmpty()) {
319+
partBuilder.thoughtSignature(thoughtSignatures.remove(0));
320+
}
321+
322+
parts.add(partBuilder.build());
323+
}
297324
}
325+
298326
return parts;
299327
}
300328
else if (message instanceof ToolResponseMessage toolResponseMessage) {
@@ -601,8 +629,22 @@ protected List<Generation> responseCandidateToGeneration(Candidate candidate) {
601629
int candidateIndex = candidate.index().orElse(0);
602630
FinishReason candidateFinishReason = candidate.finishReason().orElse(new FinishReason(FinishReason.Known.STOP));
603631

604-
Map<String, Object> messageMetadata = Map.of("candidateIndex", candidateIndex, "finishReason",
605-
candidateFinishReason);
632+
Map<String, Object> messageMetadata = new HashMap<>();
633+
messageMetadata.put("candidateIndex", candidateIndex);
634+
messageMetadata.put("finishReason", candidateFinishReason);
635+
636+
// Extract thought signatures from response parts if present
637+
if (candidate.content().isPresent() && candidate.content().get().parts().isPresent()) {
638+
List<Part> parts = candidate.content().get().parts().get();
639+
List<byte[]> thoughtSignatures = parts.stream()
640+
.filter(part -> part.thoughtSignature().isPresent())
641+
.map(part -> part.thoughtSignature().get())
642+
.toList();
643+
644+
if (!thoughtSignatures.isEmpty()) {
645+
messageMetadata.put("thoughtSignatures", thoughtSignatures);
646+
}
647+
}
606648

607649
ChatGenerationMetadata chatGenerationMetadata = ChatGenerationMetadata.builder()
608650
.finishReason(candidateFinishReason.toString())
@@ -716,10 +758,19 @@ GeminiRequest createGeminiRequest(Prompt prompt) {
716758
if (requestOptions.getPresencePenalty() != null) {
717759
configBuilder.presencePenalty(requestOptions.getPresencePenalty().floatValue());
718760
}
719-
if (requestOptions.getThinkingBudget() != null) {
720-
configBuilder
721-
.thinkingConfig(ThinkingConfig.builder().thinkingBudget(requestOptions.getThinkingBudget()).build());
761+
762+
// Build thinking config if either thinkingBudget or includeThoughts is set
763+
if (requestOptions.getThinkingBudget() != null || requestOptions.getIncludeThoughts() != null) {
764+
ThinkingConfig.Builder thinkingBuilder = ThinkingConfig.builder();
765+
if (requestOptions.getThinkingBudget() != null) {
766+
thinkingBuilder.thinkingBudget(requestOptions.getThinkingBudget());
767+
}
768+
if (requestOptions.getIncludeThoughts() != null) {
769+
thinkingBuilder.includeThoughts(requestOptions.getIncludeThoughts());
770+
}
771+
configBuilder.thinkingConfig(thinkingBuilder.build());
722772
}
773+
723774
if (requestOptions.getLabels() != null && !requestOptions.getLabels().isEmpty()) {
724775
configBuilder.labels(requestOptions.getLabels());
725776
}
@@ -1068,7 +1119,9 @@ public enum ChatModel implements ChatModelDescription {
10681119
* See: <a href=
10691120
* "https://cloud.google.com/vertex-ai/generative-ai/docs/models/gemini/2-5-flash-lite">gemini-2.5-flash-lite</a>
10701121
*/
1071-
GEMINI_2_5_FLASH_LIGHT("gemini-2.5-flash-lite");
1122+
GEMINI_2_5_FLASH_LIGHT("gemini-2.5-flash-lite"),
1123+
1124+
GEMINI_3_PRO_PREVIEW("gemini-3-pro-preview");
10721125

10731126
public final String value;
10741127

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

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,19 @@ public class GoogleGenAiChatOptions implements ToolCallingChatOptions, Structure
119119
*/
120120
private @JsonProperty("thinkingBudget") Integer thinkingBudget;
121121

122+
/**
123+
* Optional. Whether to include thoughts in the response.
124+
* When true, thoughts are returned if the model supports them and thoughts are available.
125+
*
126+
* <p><strong>IMPORTANT:</strong> For Gemini 3 Pro with function calling,
127+
* this MUST be set to true to avoid validation errors. Thought signatures
128+
* are automatically propagated in multi-turn conversations to maintain context.
129+
*
130+
* <p>Note: Enabling thoughts increases token usage and API costs.
131+
* This is part of the thinkingConfig in GenerationConfig.
132+
*/
133+
private @JsonProperty("includeThoughts") Boolean includeThoughts;
134+
122135
/**
123136
* Optional. Whether to include extended usage metadata in responses.
124137
* When true, includes thinking tokens, cached content, tool-use tokens, and modality details.
@@ -212,6 +225,7 @@ public static GoogleGenAiChatOptions fromOptions(GoogleGenAiChatOptions fromOpti
212225
options.setInternalToolExecutionEnabled(fromOptions.getInternalToolExecutionEnabled());
213226
options.setToolContext(fromOptions.getToolContext());
214227
options.setThinkingBudget(fromOptions.getThinkingBudget());
228+
options.setIncludeThoughts(fromOptions.getIncludeThoughts());
215229
options.setLabels(fromOptions.getLabels());
216230
options.setIncludeExtendedUsageMetadata(fromOptions.getIncludeExtendedUsageMetadata());
217231
options.setCachedContentName(fromOptions.getCachedContentName());
@@ -371,6 +385,14 @@ public void setThinkingBudget(Integer thinkingBudget) {
371385
this.thinkingBudget = thinkingBudget;
372386
}
373387

388+
public Boolean getIncludeThoughts() {
389+
return this.includeThoughts;
390+
}
391+
392+
public void setIncludeThoughts(Boolean includeThoughts) {
393+
this.includeThoughts = includeThoughts;
394+
}
395+
374396
public Boolean getIncludeExtendedUsageMetadata() {
375397
return this.includeExtendedUsageMetadata;
376398
}
@@ -474,6 +496,7 @@ public boolean equals(Object o) {
474496
&& Objects.equals(this.frequencyPenalty, that.frequencyPenalty)
475497
&& Objects.equals(this.presencePenalty, that.presencePenalty)
476498
&& Objects.equals(this.thinkingBudget, that.thinkingBudget)
499+
&& Objects.equals(this.includeThoughts, that.includeThoughts)
477500
&& Objects.equals(this.maxOutputTokens, that.maxOutputTokens) && Objects.equals(this.model, that.model)
478501
&& Objects.equals(this.responseMimeType, that.responseMimeType)
479502
&& Objects.equals(this.responseSchema, that.responseSchema)
@@ -487,22 +510,22 @@ public boolean equals(Object o) {
487510
@Override
488511
public int hashCode() {
489512
return Objects.hash(this.stopSequences, this.temperature, this.topP, this.topK, this.candidateCount,
490-
this.frequencyPenalty, this.presencePenalty, this.thinkingBudget, this.maxOutputTokens, this.model,
491-
this.responseMimeType, this.responseSchema, this.toolCallbacks, this.toolNames,
492-
this.googleSearchRetrieval, this.safetySettings, this.internalToolExecutionEnabled, this.toolContext,
493-
this.labels);
513+
this.frequencyPenalty, this.presencePenalty, this.thinkingBudget, this.includeThoughts,
514+
this.maxOutputTokens, this.model, this.responseMimeType, this.responseSchema, this.toolCallbacks,
515+
this.toolNames, this.googleSearchRetrieval, this.safetySettings, this.internalToolExecutionEnabled,
516+
this.toolContext, this.labels);
494517
}
495518

496519
@Override
497520
public String toString() {
498521
return "GoogleGenAiChatOptions{" + "stopSequences=" + this.stopSequences + ", temperature=" + this.temperature
499522
+ ", topP=" + this.topP + ", topK=" + this.topK + ", frequencyPenalty=" + this.frequencyPenalty
500523
+ ", presencePenalty=" + this.presencePenalty + ", thinkingBudget=" + this.thinkingBudget
501-
+ ", candidateCount=" + this.candidateCount + ", maxOutputTokens=" + this.maxOutputTokens + ", model='"
502-
+ this.model + '\'' + ", responseMimeType='" + this.responseMimeType + '\'' + ", toolCallbacks="
503-
+ this.toolCallbacks + ", toolNames=" + this.toolNames + ", googleSearchRetrieval="
504-
+ this.googleSearchRetrieval + ", safetySettings=" + this.safetySettings + ", labels=" + this.labels
505-
+ '}';
524+
+ ", includeThoughts=" + this.includeThoughts + ", candidateCount=" + this.candidateCount
525+
+ ", maxOutputTokens=" + this.maxOutputTokens + ", model='" + this.model + '\'' + ", responseMimeType='"
526+
+ this.responseMimeType + '\'' + ", toolCallbacks=" + this.toolCallbacks + ", toolNames="
527+
+ this.toolNames + ", googleSearchRetrieval=" + this.googleSearchRetrieval + ", safetySettings="
528+
+ this.safetySettings + ", labels=" + this.labels + '}';
506529
}
507530

508531
@Override
@@ -640,6 +663,11 @@ public Builder thinkingBudget(Integer thinkingBudget) {
640663
return this;
641664
}
642665

666+
public Builder includeThoughts(Boolean includeThoughts) {
667+
this.options.setIncludeThoughts(includeThoughts);
668+
return this;
669+
}
670+
643671
public Builder includeExtendedUsageMetadata(Boolean includeExtendedUsageMetadata) {
644672
this.options.setIncludeExtendedUsageMetadata(includeExtendedUsageMetadata);
645673
return this;

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ void googleSearchToolPro() {
105105
GoogleGenAiChatOptions.builder().model(ChatModel.GEMINI_2_5_PRO).googleSearchRetrieval(true).build());
106106
ChatResponse response = this.chatModel.call(prompt);
107107
assertThat(response.getResult().getOutput().getText()).containsAnyOf("Blackbeard", "Bartholomew", "Calico Jack",
108-
"Anne Bonny");
108+
"Bob", "Anne Bonny");
109109
}
110110

111111
@Test

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

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ void beforeEach() {
6464
void observationForChatOperation() {
6565

6666
var options = GoogleGenAiChatOptions.builder()
67-
.model(GoogleGenAiChatModel.ChatModel.GEMINI_2_0_FLASH.getValue())
67+
.model(GoogleGenAiChatModel.ChatModel.GEMINI_3_PRO_PREVIEW.getValue())
6868
.temperature(0.7)
6969
.stopSequences(List.of("this-is-the-end"))
7070
.maxOutputTokens(2048)
@@ -86,7 +86,7 @@ void observationForChatOperation() {
8686
void observationForStreamingOperation() {
8787

8888
var options = GoogleGenAiChatOptions.builder()
89-
.model(GoogleGenAiChatModel.ChatModel.GEMINI_2_0_FLASH.getValue())
89+
.model(GoogleGenAiChatModel.ChatModel.GEMINI_3_PRO_PREVIEW.getValue())
9090
.temperature(0.7)
9191
.stopSequences(List.of("this-is-the-end"))
9292
.maxOutputTokens(2048)
@@ -126,7 +126,7 @@ private void validate(ChatResponseMetadata responseMetadata) {
126126
AiProvider.GOOGLE_GENAI_AI.value())
127127
.hasLowCardinalityKeyValue(
128128
ChatModelObservationDocumentation.LowCardinalityKeyNames.REQUEST_MODEL.asString(),
129-
GoogleGenAiChatModel.ChatModel.GEMINI_2_0_FLASH.getValue())
129+
GoogleGenAiChatModel.ChatModel.GEMINI_3_PRO_PREVIEW.getValue())
130130
.hasHighCardinalityKeyValue(
131131
ChatModelObservationDocumentation.HighCardinalityKeyNames.REQUEST_MAX_TOKENS.asString(), "2048")
132132
.hasHighCardinalityKeyValue(
@@ -174,8 +174,9 @@ public GoogleGenAiChatModel vertexAiEmbedding(Client genAiClient, TestObservatio
174174
return GoogleGenAiChatModel.builder()
175175
.genAiClient(genAiClient)
176176
.observationRegistry(observationRegistry)
177-
.defaultOptions(
178-
GoogleGenAiChatOptions.builder().model(GoogleGenAiChatModel.ChatModel.GEMINI_2_0_FLASH).build())
177+
.defaultOptions(GoogleGenAiChatOptions.builder()
178+
.model(GoogleGenAiChatModel.ChatModel.GEMINI_3_PRO_PREVIEW)
179+
.build())
179180
.build();
180181
}
181182

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ public void setUp() {
6161
GoogleGenAiChatOptions.builder()
6262
.temperature(0.7)
6363
.topP(1.0)
64-
.model(GoogleGenAiChatModel.ChatModel.GEMINI_2_0_FLASH.getValue())
64+
.model(GoogleGenAiChatModel.ChatModel.GEMINI_3_PRO_PREVIEW.getValue())
6565
.build(),
6666
this.retryTemplate);
6767

0 commit comments

Comments
 (0)