Skip to content

Commit 7e6da6e

Browse files
ddobrinmarkpollack
authored andcommitted
Fixes #4983 - Add ThinkingLevel support in ThinkingConfig
Add support for ThinkingLevel (LOW, HIGH, THINKING_LEVEL_UNSPECIFIED) to control the depth of reasoning tokens generated by Gemini models. - Create GoogleGenAiThinkingLevel enum for type-safe thinking level values - Add thinkingLevel field to GoogleGenAiChatOptions with builder support - Add mapping method to convert Spring AI enum to SDK ThinkingLevel.Known - Update createGeminiRequest() to include thinkingLevel in ThinkingConfig - Add comprehensive unit tests for options and request building - Add integration tests for thinkingLevel with Gemini 3 Pro Preview - Document changes - Document thinkingLevel/thinkingBudget mutual exclusivity - Add model compatibility table with endpoint requirements - Use global endpoint for Gemini 3 Pro Preview tests Signed-off-by: ddobrin <ddobrin@google.com> Signed-off-by: markpollack <mark.pollack@broadcom.com>
1 parent f4eb375 commit 7e6da6e

File tree

7 files changed

+579
-12
lines changed

7 files changed

+579
-12
lines changed

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

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
import com.google.genai.types.SafetySetting;
4141
import com.google.genai.types.Schema;
4242
import com.google.genai.types.ThinkingConfig;
43+
import com.google.genai.types.ThinkingLevel;
4344
import com.google.genai.types.Tool;
4445
import io.micrometer.observation.Observation;
4546
import io.micrometer.observation.ObservationRegistry;
@@ -73,6 +74,7 @@
7374
import org.springframework.ai.google.genai.cache.GoogleGenAiCachedContentService;
7475
import org.springframework.ai.google.genai.common.GoogleGenAiConstants;
7576
import org.springframework.ai.google.genai.common.GoogleGenAiSafetySetting;
77+
import org.springframework.ai.google.genai.common.GoogleGenAiThinkingLevel;
7678
import org.springframework.ai.google.genai.metadata.GoogleGenAiUsage;
7779
import org.springframework.ai.google.genai.schema.GoogleGenAiToolCallingManager;
7880
import org.springframework.ai.model.ChatModelDescription;
@@ -753,15 +755,19 @@ GeminiRequest createGeminiRequest(Prompt prompt) {
753755
configBuilder.presencePenalty(requestOptions.getPresencePenalty().floatValue());
754756
}
755757

756-
// Build thinking config if either thinkingBudget or includeThoughts is set
757-
if (requestOptions.getThinkingBudget() != null || requestOptions.getIncludeThoughts() != null) {
758+
// Build thinking config if any thinking option is set
759+
if (requestOptions.getThinkingBudget() != null || requestOptions.getIncludeThoughts() != null
760+
|| requestOptions.getThinkingLevel() != null) {
758761
ThinkingConfig.Builder thinkingBuilder = ThinkingConfig.builder();
759762
if (requestOptions.getThinkingBudget() != null) {
760763
thinkingBuilder.thinkingBudget(requestOptions.getThinkingBudget());
761764
}
762765
if (requestOptions.getIncludeThoughts() != null) {
763766
thinkingBuilder.includeThoughts(requestOptions.getIncludeThoughts());
764767
}
768+
if (requestOptions.getThinkingLevel() != null) {
769+
thinkingBuilder.thinkingLevel(mapToGenAiThinkingLevel(requestOptions.getThinkingLevel()));
770+
}
765771
configBuilder.thinkingConfig(thinkingBuilder.build());
766772
}
767773

@@ -860,6 +866,14 @@ private static com.google.genai.types.HarmBlockThreshold mapToGenAiHarmBlockThre
860866
};
861867
}
862868

869+
private static ThinkingLevel mapToGenAiThinkingLevel(GoogleGenAiThinkingLevel level) {
870+
return switch (level) {
871+
case THINKING_LEVEL_UNSPECIFIED -> new ThinkingLevel(ThinkingLevel.Known.THINKING_LEVEL_UNSPECIFIED);
872+
case LOW -> new ThinkingLevel(ThinkingLevel.Known.LOW);
873+
case HIGH -> new ThinkingLevel(ThinkingLevel.Known.HIGH);
874+
};
875+
}
876+
863877
private List<Content> toGeminiContent(List<Message> instructions) {
864878

865879
List<Content> contents = instructions.stream()

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

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232

3333
import org.springframework.ai.google.genai.GoogleGenAiChatModel.ChatModel;
3434
import org.springframework.ai.google.genai.common.GoogleGenAiSafetySetting;
35+
import org.springframework.ai.google.genai.common.GoogleGenAiThinkingLevel;
3536
import org.springframework.ai.model.tool.ToolCallingChatOptions;
3637
import org.springframework.ai.tool.ToolCallback;
3738
import org.springframework.lang.Nullable;
@@ -126,6 +127,13 @@ public class GoogleGenAiChatOptions implements ToolCallingChatOptions {
126127
*/
127128
private @JsonProperty("includeThoughts") Boolean includeThoughts;
128129

130+
/**
131+
* Optional. The level of thinking tokens the model should generate.
132+
* LOW = minimal thinking, HIGH = extensive thinking.
133+
* This is part of the thinkingConfig in GenerationConfig.
134+
*/
135+
private @JsonProperty("thinkingLevel") GoogleGenAiThinkingLevel thinkingLevel;
136+
129137
/**
130138
* Optional. Whether to include extended usage metadata in responses.
131139
* When true, includes thinking tokens, cached content, tool-use tokens, and modality details.
@@ -220,6 +228,7 @@ public static GoogleGenAiChatOptions fromOptions(GoogleGenAiChatOptions fromOpti
220228
options.setToolContext(fromOptions.getToolContext());
221229
options.setThinkingBudget(fromOptions.getThinkingBudget());
222230
options.setIncludeThoughts(fromOptions.getIncludeThoughts());
231+
options.setThinkingLevel(fromOptions.getThinkingLevel());
223232
options.setLabels(fromOptions.getLabels());
224233
options.setIncludeExtendedUsageMetadata(fromOptions.getIncludeExtendedUsageMetadata());
225234
options.setCachedContentName(fromOptions.getCachedContentName());
@@ -379,6 +388,14 @@ public void setIncludeThoughts(Boolean includeThoughts) {
379388
this.includeThoughts = includeThoughts;
380389
}
381390

391+
public GoogleGenAiThinkingLevel getThinkingLevel() {
392+
return this.thinkingLevel;
393+
}
394+
395+
public void setThinkingLevel(GoogleGenAiThinkingLevel thinkingLevel) {
396+
this.thinkingLevel = thinkingLevel;
397+
}
398+
382399
public Boolean getIncludeExtendedUsageMetadata() {
383400
return this.includeExtendedUsageMetadata;
384401
}
@@ -471,6 +488,7 @@ public boolean equals(Object o) {
471488
&& Objects.equals(this.presencePenalty, that.presencePenalty)
472489
&& Objects.equals(this.thinkingBudget, that.thinkingBudget)
473490
&& Objects.equals(this.includeThoughts, that.includeThoughts)
491+
&& this.thinkingLevel == that.thinkingLevel
474492
&& Objects.equals(this.maxOutputTokens, that.maxOutputTokens) && Objects.equals(this.model, that.model)
475493
&& Objects.equals(this.responseMimeType, that.responseMimeType)
476494
&& Objects.equals(this.toolCallbacks, that.toolCallbacks)
@@ -484,21 +502,22 @@ public boolean equals(Object o) {
484502
public int hashCode() {
485503
return Objects.hash(this.stopSequences, this.temperature, this.topP, this.topK, this.candidateCount,
486504
this.frequencyPenalty, this.presencePenalty, this.thinkingBudget, this.includeThoughts,
487-
this.maxOutputTokens, this.model, this.responseMimeType, this.toolCallbacks, this.toolNames,
488-
this.googleSearchRetrieval, this.safetySettings, this.internalToolExecutionEnabled, this.toolContext,
489-
this.labels);
505+
this.thinkingLevel, this.maxOutputTokens, this.model, this.responseMimeType, this.toolCallbacks,
506+
this.toolNames, this.googleSearchRetrieval, this.safetySettings, this.internalToolExecutionEnabled,
507+
this.toolContext, this.labels);
490508
}
491509

492510
@Override
493511
public String toString() {
494512
return "GoogleGenAiChatOptions{" + "stopSequences=" + this.stopSequences + ", temperature=" + this.temperature
495513
+ ", topP=" + this.topP + ", topK=" + this.topK + ", frequencyPenalty=" + this.frequencyPenalty
496514
+ ", presencePenalty=" + this.presencePenalty + ", thinkingBudget=" + this.thinkingBudget
497-
+ ", includeThoughts=" + this.includeThoughts + ", candidateCount=" + this.candidateCount
498-
+ ", maxOutputTokens=" + this.maxOutputTokens + ", model='" + this.model + '\'' + ", responseMimeType='"
499-
+ this.responseMimeType + '\'' + ", toolCallbacks=" + this.toolCallbacks + ", toolNames="
500-
+ this.toolNames + ", googleSearchRetrieval=" + this.googleSearchRetrieval + ", safetySettings="
501-
+ this.safetySettings + ", labels=" + this.labels + '}';
515+
+ ", includeThoughts=" + this.includeThoughts + ", thinkingLevel=" + this.thinkingLevel
516+
+ ", candidateCount=" + this.candidateCount + ", maxOutputTokens=" + this.maxOutputTokens + ", model='"
517+
+ this.model + '\'' + ", responseMimeType='" + this.responseMimeType + '\'' + ", toolCallbacks="
518+
+ this.toolCallbacks + ", toolNames=" + this.toolNames + ", googleSearchRetrieval="
519+
+ this.googleSearchRetrieval + ", safetySettings=" + this.safetySettings + ", labels=" + this.labels
520+
+ '}';
502521
}
503522

504523
@Override
@@ -631,6 +650,11 @@ public Builder includeThoughts(Boolean includeThoughts) {
631650
return this;
632651
}
633652

653+
public Builder thinkingLevel(GoogleGenAiThinkingLevel thinkingLevel) {
654+
this.options.setThinkingLevel(thinkingLevel);
655+
return this;
656+
}
657+
634658
public Builder includeExtendedUsageMetadata(Boolean includeExtendedUsageMetadata) {
635659
this.options.setIncludeExtendedUsageMetadata(includeExtendedUsageMetadata);
636660
return this;
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/*
2+
* Copyright 2024-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.ai.google.genai.common;
18+
19+
/**
20+
* Enum representing the level of thinking tokens the model should generate. This controls
21+
* the depth of reasoning the model applies during generation.
22+
*
23+
* <p>
24+
* <strong>Model Compatibility:</strong> This option is only supported by Gemini 3 Pro
25+
* models. For Gemini 2.5 series and earlier models, use
26+
* {@link org.springframework.ai.google.genai.GoogleGenAiChatOptions#getThinkingBudget()
27+
* thinkingBudget} instead.
28+
*
29+
* <p>
30+
* <strong>Important:</strong> {@code thinkingLevel} and {@code thinkingBudget} are
31+
* mutually exclusive. You cannot use both in the same request - doing so will result in
32+
* an API error.
33+
*
34+
* @author Dan Dobrin
35+
* @since 1.1.0
36+
* @see <a href="https://ai.google.dev/gemini-api/docs/thinking">Google GenAI Thinking
37+
* documentation</a>
38+
*/
39+
public enum GoogleGenAiThinkingLevel {
40+
41+
/**
42+
* Unspecified thinking level. The model uses its default behavior.
43+
*/
44+
THINKING_LEVEL_UNSPECIFIED,
45+
46+
/**
47+
* Low thinking level. Minimal reasoning tokens are generated. Use for simple queries
48+
* where speed is preferred over deep analysis.
49+
*/
50+
LOW,
51+
52+
/**
53+
* High thinking level. Extensive reasoning tokens are generated. Use for complex
54+
* problems requiring deep analysis and step-by-step reasoning.
55+
*/
56+
HIGH
57+
58+
}

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

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
import org.springframework.ai.chat.prompt.Prompt;
3434
import org.springframework.ai.content.Media;
3535
import org.springframework.ai.google.genai.GoogleGenAiChatModel.GeminiRequest;
36+
import org.springframework.ai.google.genai.common.GoogleGenAiThinkingLevel;
3637
import org.springframework.ai.google.genai.tool.MockWeatherService;
3738
import org.springframework.ai.model.tool.ToolCallingChatOptions;
3839
import org.springframework.ai.model.tool.ToolCallingManager;
@@ -418,4 +419,105 @@ public void createRequestWithLabels() {
418419
assertThat(request.config().labels().get()).containsEntry("env", "test");
419420
}
420421

422+
@Test
423+
public void createRequestWithThinkingLevel() {
424+
var client = GoogleGenAiChatModel.builder()
425+
.genAiClient(this.genAiClient)
426+
.defaultOptions(GoogleGenAiChatOptions.builder()
427+
.model("DEFAULT_MODEL")
428+
.thinkingLevel(GoogleGenAiThinkingLevel.HIGH)
429+
.build())
430+
.build();
431+
432+
GeminiRequest request = client
433+
.createGeminiRequest(client.buildRequestPrompt(new Prompt("Test message content")));
434+
435+
assertThat(request.contents()).hasSize(1);
436+
assertThat(request.modelName()).isEqualTo("DEFAULT_MODEL");
437+
438+
// Verify thinkingConfig is present and contains thinkingLevel
439+
assertThat(request.config().thinkingConfig()).isPresent();
440+
assertThat(request.config().thinkingConfig().get().thinkingLevel()).isPresent();
441+
assertThat(request.config().thinkingConfig().get().thinkingLevel().get().toString()).isEqualTo("HIGH");
442+
}
443+
444+
@Test
445+
public void createRequestWithThinkingLevelOverride() {
446+
var client = GoogleGenAiChatModel.builder()
447+
.genAiClient(this.genAiClient)
448+
.defaultOptions(GoogleGenAiChatOptions.builder()
449+
.model("DEFAULT_MODEL")
450+
.thinkingLevel(GoogleGenAiThinkingLevel.LOW)
451+
.build())
452+
.build();
453+
454+
// Override default thinkingLevel with prompt-specific value
455+
GeminiRequest request = client.createGeminiRequest(client.buildRequestPrompt(new Prompt("Test message content",
456+
GoogleGenAiChatOptions.builder().thinkingLevel(GoogleGenAiThinkingLevel.HIGH).build())));
457+
458+
assertThat(request.config().thinkingConfig()).isPresent();
459+
assertThat(request.config().thinkingConfig().get().thinkingLevel()).isPresent();
460+
assertThat(request.config().thinkingConfig().get().thinkingLevel().get().toString()).isEqualTo("HIGH");
461+
}
462+
463+
@Test
464+
public void createRequestWithThinkingLevelAndBudgetCombined() {
465+
var client = GoogleGenAiChatModel.builder()
466+
.genAiClient(this.genAiClient)
467+
.defaultOptions(GoogleGenAiChatOptions.builder()
468+
.model("DEFAULT_MODEL")
469+
.thinkingBudget(8192)
470+
.thinkingLevel(GoogleGenAiThinkingLevel.HIGH)
471+
.includeThoughts(true)
472+
.build())
473+
.build();
474+
475+
GeminiRequest request = client
476+
.createGeminiRequest(client.buildRequestPrompt(new Prompt("Test message content")));
477+
478+
assertThat(request.config().thinkingConfig()).isPresent();
479+
var thinkingConfig = request.config().thinkingConfig().get();
480+
assertThat(thinkingConfig.thinkingBudget()).isPresent();
481+
assertThat(thinkingConfig.thinkingBudget().get()).isEqualTo(8192);
482+
assertThat(thinkingConfig.thinkingLevel()).isPresent();
483+
assertThat(thinkingConfig.thinkingLevel().get().toString()).isEqualTo("HIGH");
484+
assertThat(thinkingConfig.includeThoughts()).isPresent();
485+
assertThat(thinkingConfig.includeThoughts().get()).isTrue();
486+
}
487+
488+
@Test
489+
public void createRequestWithNullThinkingLevel() {
490+
var client = GoogleGenAiChatModel.builder()
491+
.genAiClient(this.genAiClient)
492+
.defaultOptions(GoogleGenAiChatOptions.builder().model("DEFAULT_MODEL").thinkingLevel(null).build())
493+
.build();
494+
495+
GeminiRequest request = client
496+
.createGeminiRequest(client.buildRequestPrompt(new Prompt("Test message content")));
497+
498+
// Verify thinkingConfig is not present when only thinkingLevel is null
499+
assertThat(request.config().thinkingConfig()).isEmpty();
500+
}
501+
502+
@Test
503+
public void createRequestWithOnlyThinkingLevel() {
504+
var client = GoogleGenAiChatModel.builder()
505+
.genAiClient(this.genAiClient)
506+
.defaultOptions(GoogleGenAiChatOptions.builder()
507+
.model("DEFAULT_MODEL")
508+
.thinkingLevel(GoogleGenAiThinkingLevel.LOW)
509+
.build())
510+
.build();
511+
512+
GeminiRequest request = client
513+
.createGeminiRequest(client.buildRequestPrompt(new Prompt("Test message content")));
514+
515+
// Verify thinkingConfig is present when only thinkingLevel is set
516+
assertThat(request.config().thinkingConfig()).isPresent();
517+
assertThat(request.config().thinkingConfig().get().thinkingLevel()).isPresent();
518+
assertThat(request.config().thinkingConfig().get().thinkingLevel().get().toString()).isEqualTo("LOW");
519+
// Budget should not be present
520+
assertThat(request.config().thinkingConfig().get().thinkingBudget()).isEmpty();
521+
}
522+
421523
}

0 commit comments

Comments
 (0)