diff --git a/models/spring-ai-google-genai/src/main/java/org/springframework/ai/google/genai/GoogleGenAiChatModel.java b/models/spring-ai-google-genai/src/main/java/org/springframework/ai/google/genai/GoogleGenAiChatModel.java index e6378477c84..26c42b6514f 100644 --- a/models/spring-ai-google-genai/src/main/java/org/springframework/ai/google/genai/GoogleGenAiChatModel.java +++ b/models/spring-ai-google-genai/src/main/java/org/springframework/ai/google/genai/GoogleGenAiChatModel.java @@ -36,12 +36,16 @@ import com.google.genai.types.GenerateContentConfig; import com.google.genai.types.GenerateContentResponse; import com.google.genai.types.GoogleSearch; +import com.google.genai.types.GoogleMaps; +import com.google.genai.types.LatLng; import com.google.genai.types.Part; +import com.google.genai.types.RetrievalConfig; import com.google.genai.types.SafetySetting; import com.google.genai.types.Schema; import com.google.genai.types.ThinkingConfig; import com.google.genai.types.ThinkingLevel; import com.google.genai.types.Tool; +import com.google.genai.types.ToolConfig; import io.micrometer.observation.Observation; import io.micrometer.observation.ObservationRegistry; import io.micrometer.observation.contextpropagation.ObservationThreadLocalAccessor; @@ -515,6 +519,14 @@ Prompt buildRequestPrompt(Prompt prompt) { requestOptions.setGoogleSearchRetrieval(ModelOptionsUtils.mergeOption( runtimeOptions.getGoogleSearchRetrieval(), this.defaultOptions.getGoogleSearchRetrieval())); + requestOptions.setGoogleMaps( + ModelOptionsUtils.mergeOption(runtimeOptions.getGoogleMaps(), this.defaultOptions.getGoogleMaps())); + requestOptions.setGoogleMapsWidget(ModelOptionsUtils.mergeOption(runtimeOptions.getGoogleMapsWidget(), + this.defaultOptions.getGoogleMapsWidget())); + requestOptions.setLatitude( + ModelOptionsUtils.mergeOption(runtimeOptions.getLatitude(), this.defaultOptions.getLatitude())); + requestOptions.setLongitude( + ModelOptionsUtils.mergeOption(runtimeOptions.getLongitude(), this.defaultOptions.getLongitude())); requestOptions.setSafetySettings(ModelOptionsUtils.mergeOption(runtimeOptions.getSafetySettings(), this.defaultOptions.getSafetySettings())); requestOptions @@ -527,6 +539,10 @@ Prompt buildRequestPrompt(Prompt prompt) { requestOptions.setToolContext(this.defaultOptions.getToolContext()); requestOptions.setGoogleSearchRetrieval(this.defaultOptions.getGoogleSearchRetrieval()); + requestOptions.setGoogleMaps(this.defaultOptions.getGoogleMaps()); + requestOptions.setGoogleMapsWidget(this.defaultOptions.getGoogleMapsWidget()); + requestOptions.setLatitude(this.defaultOptions.getLatitude()); + requestOptions.setLongitude(this.defaultOptions.getLongitude()); requestOptions.setSafetySettings(this.defaultOptions.getSafetySettings()); requestOptions.setLabels(this.defaultOptions.getLabels()); } @@ -648,9 +664,10 @@ protected List responseCandidateToGeneration(Candidate candidate) { } } - ChatGenerationMetadata chatGenerationMetadata = ChatGenerationMetadata.builder() - .finishReason(candidateFinishReason.toString()) - .build(); + var generationMetadataBuilder = ChatGenerationMetadata.builder().finishReason(candidateFinishReason.toString()); + candidate.groundingMetadata() + .ifPresent(grounding -> generationMetadataBuilder.metadata("groundingMetadata", grounding)); + ChatGenerationMetadata chatGenerationMetadata = generationMetadataBuilder.build(); boolean isFunctionCall = candidate.content().isPresent() && candidate.content().get().parts().isPresent() && candidate.content().get().parts().get().stream().allMatch(part -> part.functionCall().isPresent()); @@ -725,6 +742,7 @@ GeminiRequest createGeminiRequest(Prompt prompt) { // Build GenerateContentConfig GenerateContentConfig.Builder configBuilder = GenerateContentConfig.builder(); + RetrievalConfig.Builder retrievalConfigBuilder = RetrievalConfig.builder(); String modelName = requestOptions.getModel() != null ? requestOptions.getModel() : this.defaultOptions.getModel(); @@ -806,6 +824,23 @@ GeminiRequest createGeminiRequest(Prompt prompt) { tools.add(googleSearchRetrievalTool); } + if (requestOptions.getGoogleMaps()) { + var googleMapsBuilder = GoogleMaps.builder(); + if (requestOptions.getGoogleMapsWidget()) { + googleMapsBuilder.enableWidget(true); + } + tools.add(Tool.builder().googleMaps(googleMapsBuilder.build()).build()); + } + + if (requestOptions.getLatitude() != null && requestOptions.getLongitude() != null) { + retrievalConfigBuilder.latLng(LatLng.builder() + .latitude(requestOptions.getLatitude()) + .longitude(requestOptions.getLongitude()) + .build()); + } + + configBuilder.toolConfig(ToolConfig.builder().retrievalConfig(retrievalConfigBuilder).build()); + if (!CollectionUtils.isEmpty(tools)) { configBuilder.tools(tools); } diff --git a/models/spring-ai-google-genai/src/main/java/org/springframework/ai/google/genai/GoogleGenAiChatOptions.java b/models/spring-ai-google-genai/src/main/java/org/springframework/ai/google/genai/GoogleGenAiChatOptions.java index 0408d978c49..e07a37e4465 100644 --- a/models/spring-ai-google-genai/src/main/java/org/springframework/ai/google/genai/GoogleGenAiChatOptions.java +++ b/models/spring-ai-google-genai/src/main/java/org/springframework/ai/google/genai/GoogleGenAiChatOptions.java @@ -202,6 +202,18 @@ public class GoogleGenAiChatOptions implements ToolCallingChatOptions, Structure @JsonIgnore private Boolean googleSearchRetrieval = false; + @JsonIgnore + private Boolean googleMaps = false; + + @JsonIgnore + private Boolean googleMapsWidget = false; + + @JsonIgnore + private Double latitude; + + @JsonIgnore + private Double longitude; + @JsonIgnore private List safetySettings = new ArrayList<>(); @@ -241,6 +253,10 @@ public static GoogleGenAiChatOptions fromOptions(GoogleGenAiChatOptions fromOpti options.setUseCachedContent(fromOptions.getUseCachedContent()); options.setAutoCacheThreshold(fromOptions.getAutoCacheThreshold()); options.setAutoCacheTtl(fromOptions.getAutoCacheTtl()); + options.setGoogleMaps(fromOptions.getGoogleMaps()); + options.setGoogleMapsWidget(fromOptions.getGoogleMapsWidget()); + options.setLatitude(fromOptions.getLatitude()); + options.setLongitude(fromOptions.getLongitude()); return options; } @@ -458,6 +474,38 @@ public void setGoogleSearchRetrieval(Boolean googleSearchRetrieval) { this.googleSearchRetrieval = googleSearchRetrieval; } + public Boolean getGoogleMaps() { + return this.googleMaps; + } + + public void setGoogleMaps(Boolean googleMaps) { + this.googleMaps = googleMaps; + } + + public Boolean getGoogleMapsWidget() { + return this.googleMapsWidget; + } + + public void setGoogleMapsWidget(Boolean googleMapsWidget) { + this.googleMapsWidget = googleMapsWidget; + } + + public Double getLatitude() { + return this.latitude; + } + + public void setLatitude(Double latitude) { + this.latitude = latitude; + } + + public Double getLongitude() { + return this.longitude; + } + + public void setLongitude(Double longitude) { + this.longitude = longitude; + } + public List getSafetySettings() { return this.safetySettings; } @@ -522,7 +570,10 @@ public boolean equals(Object o) { && Objects.equals(this.toolNames, that.toolNames) && Objects.equals(this.safetySettings, that.safetySettings) && Objects.equals(this.internalToolExecutionEnabled, that.internalToolExecutionEnabled) - && Objects.equals(this.toolContext, that.toolContext) && Objects.equals(this.labels, that.labels); + && Objects.equals(this.toolContext, that.toolContext) && Objects.equals(this.labels, that.labels) + && Objects.equals(this.googleMaps, that.googleMaps) + && Objects.equals(this.googleMapsWidget, that.googleMapsWidget) + && Objects.equals(this.latitude, that.latitude) && Objects.equals(this.longitude, that.longitude); } @Override @@ -531,7 +582,8 @@ public int hashCode() { this.frequencyPenalty, this.presencePenalty, this.thinkingBudget, this.includeThoughts, this.thinkingLevel, this.maxOutputTokens, this.model, this.responseMimeType, this.responseSchema, this.toolCallbacks, this.toolNames, this.googleSearchRetrieval, this.safetySettings, - this.internalToolExecutionEnabled, this.toolContext, this.labels); + this.internalToolExecutionEnabled, this.toolContext, this.labels, this.googleMaps, + this.googleMapsWidget, this.latitude, this.longitude); } @Override @@ -544,7 +596,8 @@ public String toString() { + this.model + '\'' + ", responseMimeType='" + this.responseMimeType + '\'' + ", toolCallbacks=" + this.toolCallbacks + ", toolNames=" + this.toolNames + ", googleSearchRetrieval=" + this.googleSearchRetrieval + ", safetySettings=" + this.safetySettings + ", labels=" + this.labels - + '}'; + + ", googleMaps=" + this.googleMaps + ", googleMapsWidget=" + this.googleMapsWidget + ", latitude=" + + this.latitude + ", longitude=" + this.longitude + '}'; } @Override @@ -656,6 +709,26 @@ public Builder googleSearchRetrieval(boolean googleSearch) { return this; } + public Builder googleMaps(boolean googleMaps) { + this.options.googleMaps = googleMaps; + return this; + } + + public Builder googleMapsWidget(boolean googleMapsWidget) { + this.options.googleMapsWidget = googleMapsWidget; + return this; + } + + public Builder latitude(Double latitude) { + this.options.latitude = latitude; + return this; + } + + public Builder longitude(Double longitude) { + this.options.longitude = longitude; + return this; + } + public Builder safetySettings(List safetySettings) { Assert.notNull(safetySettings, "safetySettings must not be null"); this.options.safetySettings = safetySettings; diff --git a/models/spring-ai-google-genai/src/test/java/org/springframework/ai/google/genai/GoogleGenAiChatModelIT.java b/models/spring-ai-google-genai/src/test/java/org/springframework/ai/google/genai/GoogleGenAiChatModelIT.java index 9c294af5188..7322c43459f 100644 --- a/models/spring-ai-google-genai/src/test/java/org/springframework/ai/google/genai/GoogleGenAiChatModelIT.java +++ b/models/spring-ai-google-genai/src/test/java/org/springframework/ai/google/genai/GoogleGenAiChatModelIT.java @@ -25,6 +25,7 @@ import java.util.stream.Stream; import com.google.genai.Client; +import com.google.genai.types.GroundingMetadata; import io.micrometer.observation.ObservationRegistry; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; @@ -118,6 +119,113 @@ void googleSearchToolFlash() { assertThat(response.getResult().getOutput().getText()).containsAnyOf("Blackbeard", "Bartholomew", "Bob"); } + @Test + void googleMapsToolPro() { + Prompt prompt = new Prompt("Could you recommend some tourist spots around the White House?", + GoogleGenAiChatOptions.builder().model(ChatModel.GEMINI_2_5_PRO).googleMaps(true).build()); + ChatResponse response = this.chatModel.call(prompt); + assertThat(response.getResult().getOutput().getText()).containsAnyOf("Washington Monument", "Lincoln Memorial"); + assertThat(response.getResult().getMetadata().containsKey("groundingMetadata")).isTrue(); + + GroundingMetadata groundingMetadata = response.getResult().getMetadata().get("groundingMetadata"); + assertThat(groundingMetadata.groundingChunks()).isNotEmpty(); + assertThat(groundingMetadata.groundingSupports()).isNotEmpty(); + assertThat(groundingMetadata.retrievalQueries()).isNotEmpty(); + assertThat(groundingMetadata.googleMapsWidgetContextToken()).isNotPresent(); + } + + @Test + void googleMapsToolFlash() { + Prompt prompt = new Prompt("Could you recommend some tourist spots around the White House?", + GoogleGenAiChatOptions.builder().model(ChatModel.GEMINI_2_0_FLASH).googleMaps(true).build()); + ChatResponse response = this.chatModel.call(prompt); + assertThat(response.getResult().getMetadata().containsKey("groundingMetadata")).isTrue(); + + GroundingMetadata groundingMetadata = response.getResult().getMetadata().get("groundingMetadata"); + assertThat(groundingMetadata.groundingChunks()).isNotEmpty(); + assertThat(groundingMetadata.groundingSupports()).isNotEmpty(); + assertThat(groundingMetadata.retrievalQueries()).isNotEmpty(); + assertThat(groundingMetadata.googleMapsWidgetContextToken()).isNotPresent(); + } + + @Test + void googleMapsToolProWithWidget() { + Prompt prompt = new Prompt("Could you recommend some tourist spots around the White House?", + GoogleGenAiChatOptions.builder() + .model(ChatModel.GEMINI_2_5_PRO) + .googleMaps(true) + .googleMapsWidget(true) + .build()); + ChatResponse response = this.chatModel.call(prompt); + assertThat(response.getResult().getOutput().getText()).containsAnyOf("Washington Monument", "Lincoln Memorial"); + assertThat(response.getResult().getMetadata().containsKey("groundingMetadata")).isTrue(); + + GroundingMetadata groundingMetadata = response.getResult().getMetadata().get("groundingMetadata"); + assertThat(groundingMetadata.groundingChunks()).isNotEmpty(); + assertThat(groundingMetadata.groundingSupports()).isNotEmpty(); + assertThat(groundingMetadata.retrievalQueries()).isNotEmpty(); + assertThat(groundingMetadata.googleMapsWidgetContextToken()).isPresent(); + } + + @Test + void googleMapsToolFlashWithWidget() { + Prompt prompt = new Prompt("Could you recommend some tourist spots around the White House?", + GoogleGenAiChatOptions.builder() + .model(ChatModel.GEMINI_2_0_FLASH) + .googleMaps(true) + .googleMapsWidget(true) + .build()); + ChatResponse response = this.chatModel.call(prompt); + assertThat(response.getResult().getOutput().getText()).containsAnyOf("Washington Monument", "Lincoln Memorial"); + assertThat(response.getResult().getMetadata().containsKey("groundingMetadata")).isTrue(); + + GroundingMetadata groundingMetadata = response.getResult().getMetadata().get("groundingMetadata"); + assertThat(groundingMetadata.groundingChunks()).isNotEmpty(); + assertThat(groundingMetadata.groundingSupports()).isNotEmpty(); + assertThat(groundingMetadata.retrievalQueries()).isNotEmpty(); + assertThat(groundingMetadata.googleMapsWidgetContextToken()).isPresent(); + } + + @Test + void googleMapsToolProWithLatLng() { + Prompt prompt = new Prompt("Please tell me about some tourist spots near my current location", + GoogleGenAiChatOptions.builder() + .model(ChatModel.GEMINI_2_5_PRO) + .googleMaps(true) + .latitude(38.890307) + .longitude(-77.036256) + .build()); + ChatResponse response = this.chatModel.call(prompt); + assertThat(response.getResult().getOutput().getText()).containsAnyOf("Washington Monument", "Lincoln Memorial"); + assertThat(response.getResult().getMetadata().containsKey("groundingMetadata")).isTrue(); + + GroundingMetadata groundingMetadata = response.getResult().getMetadata().get("groundingMetadata"); + assertThat(groundingMetadata.groundingChunks()).isNotEmpty(); + assertThat(groundingMetadata.groundingSupports()).isNotEmpty(); + assertThat(groundingMetadata.retrievalQueries()).isNotEmpty(); + assertThat(groundingMetadata.googleMapsWidgetContextToken()).isNotPresent(); + } + + @Test + void googleMapsToolFlashWithLatLng() { + Prompt prompt = new Prompt("Please tell me about some tourist spots near my current location", + GoogleGenAiChatOptions.builder() + .model(ChatModel.GEMINI_2_0_FLASH) + .googleMaps(true) + .latitude(38.890307) + .longitude(-77.036256) + .build()); + ChatResponse response = this.chatModel.call(prompt); + assertThat(response.getResult().getOutput().getText()).containsAnyOf("Washington Monument", "Lincoln Memorial"); + assertThat(response.getResult().getMetadata().containsKey("groundingMetadata")).isTrue(); + + GroundingMetadata groundingMetadata = response.getResult().getMetadata().get("groundingMetadata"); + assertThat(groundingMetadata.groundingChunks()).isNotEmpty(); + assertThat(groundingMetadata.groundingSupports()).isNotEmpty(); + assertThat(groundingMetadata.retrievalQueries()).isNotEmpty(); + assertThat(groundingMetadata.googleMapsWidgetContextToken()).isNotPresent(); + } + @Test @Disabled void testSafetySettings() { diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/google-genai-chat.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/google-genai-chat.adoc index 95d81faf763..4814d10d337 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/google-genai-chat.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/google-genai-chat.adoc @@ -108,6 +108,10 @@ The prefix `spring.ai.google.genai.chat` is the property prefix that lets you co | spring.ai.google.genai.chat.options.model | Supported https://ai.google.dev/gemini-api/docs/models[Google GenAI Chat models] to use include `gemini-2.0-flash`, `gemini-2.0-flash-lite`, `gemini-pro`, and `gemini-1.5-flash`. | gemini-2.0-flash | spring.ai.google.genai.chat.options.response-mime-type | Output response mimetype of the generated candidate text. | `text/plain`: (default) Text output or `application/json`: JSON response. | spring.ai.google.genai.chat.options.google-search-retrieval | Use Google search Grounding feature | `true` or `false`, default `false`. +| spring.ai.google.genai.chat.options.google-maps | Use Grounding with Google Maps tool | `true` or `false`, default `false`. +| spring.ai.google.genai.chat.options.google-maps-widget | Request Google Maps widget context token in the response (requires `google-maps=true`) | `true` or `false`, default `false`. +| spring.ai.google.genai.chat.options.latitude | Latitude for Google Maps grounding (must be set together with `longitude` when provided) | - +| spring.ai.google.genai.chat.options.longitude | Longitude for Google Maps grounding (must be set together with `latitude` when provided) | - | spring.ai.google.genai.chat.options.temperature | Controls the randomness of the output. Values can range over [0.0,1.0], inclusive. A value closer to 1.0 will produce responses that are more varied, while a value closer to 0.0 will typically result in less surprising responses from the generative. | - | spring.ai.google.genai.chat.options.top-k | The maximum number of tokens to consider when sampling. The generative uses combined Top-k and nucleus sampling. Top-k sampling considers the set of topK most probable tokens. | - | spring.ai.google.genai.chat.options.top-p | The maximum cumulative probability of tokens to consider when sampling. The generative uses combined Top-k and nucleus sampling. Nucleus sampling considers the smallest set of tokens whose probability sum is at least topP. | -