Skip to content

Commit 740a61c

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 2710cab commit 740a61c

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;
@@ -759,15 +761,19 @@ GeminiRequest createGeminiRequest(Prompt prompt) {
759761
configBuilder.presencePenalty(requestOptions.getPresencePenalty().floatValue());
760762
}
761763

762-
// Build thinking config if either thinkingBudget or includeThoughts is set
763-
if (requestOptions.getThinkingBudget() != null || requestOptions.getIncludeThoughts() != null) {
764+
// Build thinking config if any thinking option is set
765+
if (requestOptions.getThinkingBudget() != null || requestOptions.getIncludeThoughts() != null
766+
|| requestOptions.getThinkingLevel() != null) {
764767
ThinkingConfig.Builder thinkingBuilder = ThinkingConfig.builder();
765768
if (requestOptions.getThinkingBudget() != null) {
766769
thinkingBuilder.thinkingBudget(requestOptions.getThinkingBudget());
767770
}
768771
if (requestOptions.getIncludeThoughts() != null) {
769772
thinkingBuilder.includeThoughts(requestOptions.getIncludeThoughts());
770773
}
774+
if (requestOptions.getThinkingLevel() != null) {
775+
thinkingBuilder.thinkingLevel(mapToGenAiThinkingLevel(requestOptions.getThinkingLevel()));
776+
}
771777
configBuilder.thinkingConfig(thinkingBuilder.build());
772778
}
773779

@@ -866,6 +872,14 @@ private static com.google.genai.types.HarmBlockThreshold mapToGenAiHarmBlockThre
866872
};
867873
}
868874

875+
private static ThinkingLevel mapToGenAiThinkingLevel(GoogleGenAiThinkingLevel level) {
876+
return switch (level) {
877+
case THINKING_LEVEL_UNSPECIFIED -> new ThinkingLevel(ThinkingLevel.Known.THINKING_LEVEL_UNSPECIFIED);
878+
case LOW -> new ThinkingLevel(ThinkingLevel.Known.LOW);
879+
case HIGH -> new ThinkingLevel(ThinkingLevel.Known.HIGH);
880+
};
881+
}
882+
869883
private List<Content> toGeminiContent(List<Message> instructions) {
870884

871885
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.StructuredOutputChatOptions;
3637
import org.springframework.ai.model.tool.ToolCallingChatOptions;
3738
import org.springframework.ai.tool.ToolCallback;
@@ -132,6 +133,13 @@ public class GoogleGenAiChatOptions implements ToolCallingChatOptions, Structure
132133
*/
133134
private @JsonProperty("includeThoughts") Boolean includeThoughts;
134135

136+
/**
137+
* Optional. The level of thinking tokens the model should generate.
138+
* LOW = minimal thinking, HIGH = extensive thinking.
139+
* This is part of the thinkingConfig in GenerationConfig.
140+
*/
141+
private @JsonProperty("thinkingLevel") GoogleGenAiThinkingLevel thinkingLevel;
142+
135143
/**
136144
* Optional. Whether to include extended usage metadata in responses.
137145
* When true, includes thinking tokens, cached content, tool-use tokens, and modality details.
@@ -226,6 +234,7 @@ public static GoogleGenAiChatOptions fromOptions(GoogleGenAiChatOptions fromOpti
226234
options.setToolContext(fromOptions.getToolContext());
227235
options.setThinkingBudget(fromOptions.getThinkingBudget());
228236
options.setIncludeThoughts(fromOptions.getIncludeThoughts());
237+
options.setThinkingLevel(fromOptions.getThinkingLevel());
229238
options.setLabels(fromOptions.getLabels());
230239
options.setIncludeExtendedUsageMetadata(fromOptions.getIncludeExtendedUsageMetadata());
231240
options.setCachedContentName(fromOptions.getCachedContentName());
@@ -393,6 +402,14 @@ public void setIncludeThoughts(Boolean includeThoughts) {
393402
this.includeThoughts = includeThoughts;
394403
}
395404

405+
public GoogleGenAiThinkingLevel getThinkingLevel() {
406+
return this.thinkingLevel;
407+
}
408+
409+
public void setThinkingLevel(GoogleGenAiThinkingLevel thinkingLevel) {
410+
this.thinkingLevel = thinkingLevel;
411+
}
412+
396413
public Boolean getIncludeExtendedUsageMetadata() {
397414
return this.includeExtendedUsageMetadata;
398415
}
@@ -497,6 +514,7 @@ public boolean equals(Object o) {
497514
&& Objects.equals(this.presencePenalty, that.presencePenalty)
498515
&& Objects.equals(this.thinkingBudget, that.thinkingBudget)
499516
&& Objects.equals(this.includeThoughts, that.includeThoughts)
517+
&& this.thinkingLevel == that.thinkingLevel
500518
&& Objects.equals(this.maxOutputTokens, that.maxOutputTokens) && Objects.equals(this.model, that.model)
501519
&& Objects.equals(this.responseMimeType, that.responseMimeType)
502520
&& Objects.equals(this.responseSchema, that.responseSchema)
@@ -511,21 +529,22 @@ public boolean equals(Object o) {
511529
public int hashCode() {
512530
return Objects.hash(this.stopSequences, this.temperature, this.topP, this.topK, this.candidateCount,
513531
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);
532+
this.thinkingLevel, this.maxOutputTokens, this.model, this.responseMimeType, this.responseSchema,
533+
this.toolCallbacks, this.toolNames, this.googleSearchRetrieval, this.safetySettings,
534+
this.internalToolExecutionEnabled, this.toolContext, this.labels);
517535
}
518536

519537
@Override
520538
public String toString() {
521539
return "GoogleGenAiChatOptions{" + "stopSequences=" + this.stopSequences + ", temperature=" + this.temperature
522540
+ ", topP=" + this.topP + ", topK=" + this.topK + ", frequencyPenalty=" + this.frequencyPenalty
523541
+ ", presencePenalty=" + this.presencePenalty + ", thinkingBudget=" + this.thinkingBudget
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 + '}';
542+
+ ", includeThoughts=" + this.includeThoughts + ", thinkingLevel=" + this.thinkingLevel
543+
+ ", candidateCount=" + this.candidateCount + ", maxOutputTokens=" + this.maxOutputTokens + ", model='"
544+
+ this.model + '\'' + ", responseMimeType='" + this.responseMimeType + '\'' + ", toolCallbacks="
545+
+ this.toolCallbacks + ", toolNames=" + this.toolNames + ", googleSearchRetrieval="
546+
+ this.googleSearchRetrieval + ", safetySettings=" + this.safetySettings + ", labels=" + this.labels
547+
+ '}';
529548
}
530549

531550
@Override
@@ -668,6 +687,11 @@ public Builder includeThoughts(Boolean includeThoughts) {
668687
return this;
669688
}
670689

690+
public Builder thinkingLevel(GoogleGenAiThinkingLevel thinkingLevel) {
691+
this.options.setThinkingLevel(thinkingLevel);
692+
return this;
693+
}
694+
671695
public Builder includeExtendedUsageMetadata(Boolean includeExtendedUsageMetadata) {
672696
this.options.setIncludeExtendedUsageMetadata(includeExtendedUsageMetadata);
673697
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)