From 06fd7124ba544ce156fe464b573713cf421d7469 Mon Sep 17 00:00:00 2001 From: Olivier LE-QUELLEC Date: Fri, 5 Jun 2026 18:59:01 +0200 Subject: [PATCH 1/4] Google GenAi Image support Signed-off-by: Olivier LE-QUELLEC --- .../pom.xml | 14 + .../GoogleGenAiImageAutoConfiguration.java | 65 ++ ...GenAiImageConnectionAutoConfiguration.java | 74 ++ .../GoogleGenAiImageConnectionProperties.java | 101 +++ .../image/GoogleGenAiImageProperties.java | 362 +++++++++ .../autoconfigure/image/package-info.java | 21 + ...ot.autoconfigure.AutoConfiguration.imports | 2 + .../GoogleGenAiImageAutoConfigurationIT.java | 106 +++ .../GoogleGenAiImagePropertiesTests.java | 130 +++ models/spring-ai-google-genai-image/pom.xml | 87 ++ .../GoogleGenAiImageConnectionDetails.java | 181 +++++ .../GoogleGenAiImageGenerationMetadata.java | 90 +++ .../genai/image/GoogleGenAiImageModel.java | 328 ++++++++ .../image/GoogleGenAiImageModelName.java | 54 ++ .../genai/image/GoogleGenAiImageOptions.java | 753 ++++++++++++++++++ .../ai/google/genai/image/package-info.java | 21 + .../genai/image/GoogleGenAiImageModelIT.java | 96 +++ .../GoogleGenAiImageModelObservationIT.java | 119 +++ .../image/GoogleGenAiImageRetryTests.java | 171 ++++ pom.xml | 4 +- spring-ai-bom/pom.xml | 12 + .../pages/api/image/google-genai-image.adoc | 279 +++++++ .../pom.xml | 52 ++ 23 files changed, 3121 insertions(+), 1 deletion(-) create mode 100644 auto-configurations/models/spring-ai-autoconfigure-model-google-genai/src/main/java/org/springframework/ai/model/google/genai/autoconfigure/image/GoogleGenAiImageAutoConfiguration.java create mode 100644 auto-configurations/models/spring-ai-autoconfigure-model-google-genai/src/main/java/org/springframework/ai/model/google/genai/autoconfigure/image/GoogleGenAiImageConnectionAutoConfiguration.java create mode 100644 auto-configurations/models/spring-ai-autoconfigure-model-google-genai/src/main/java/org/springframework/ai/model/google/genai/autoconfigure/image/GoogleGenAiImageConnectionProperties.java create mode 100644 auto-configurations/models/spring-ai-autoconfigure-model-google-genai/src/main/java/org/springframework/ai/model/google/genai/autoconfigure/image/GoogleGenAiImageProperties.java create mode 100644 auto-configurations/models/spring-ai-autoconfigure-model-google-genai/src/main/java/org/springframework/ai/model/google/genai/autoconfigure/image/package-info.java create mode 100644 auto-configurations/models/spring-ai-autoconfigure-model-google-genai/src/test/java/org/springframework/ai/model/google/genai/autoconfigure/image/GoogleGenAiImageAutoConfigurationIT.java create mode 100644 auto-configurations/models/spring-ai-autoconfigure-model-google-genai/src/test/java/org/springframework/ai/model/google/genai/autoconfigure/image/GoogleGenAiImagePropertiesTests.java create mode 100644 models/spring-ai-google-genai-image/pom.xml create mode 100644 models/spring-ai-google-genai-image/src/main/java/org/springframework/ai/google/genai/image/GoogleGenAiImageConnectionDetails.java create mode 100644 models/spring-ai-google-genai-image/src/main/java/org/springframework/ai/google/genai/image/GoogleGenAiImageGenerationMetadata.java create mode 100644 models/spring-ai-google-genai-image/src/main/java/org/springframework/ai/google/genai/image/GoogleGenAiImageModel.java create mode 100644 models/spring-ai-google-genai-image/src/main/java/org/springframework/ai/google/genai/image/GoogleGenAiImageModelName.java create mode 100644 models/spring-ai-google-genai-image/src/main/java/org/springframework/ai/google/genai/image/GoogleGenAiImageOptions.java create mode 100644 models/spring-ai-google-genai-image/src/main/java/org/springframework/ai/google/genai/image/package-info.java create mode 100644 models/spring-ai-google-genai-image/src/test/java/org/springframework/ai/google/genai/image/GoogleGenAiImageModelIT.java create mode 100644 models/spring-ai-google-genai-image/src/test/java/org/springframework/ai/google/genai/image/GoogleGenAiImageModelObservationIT.java create mode 100644 models/spring-ai-google-genai-image/src/test/java/org/springframework/ai/google/genai/image/GoogleGenAiImageRetryTests.java create mode 100644 spring-ai-docs/src/main/antora/modules/ROOT/pages/api/image/google-genai-image.adoc create mode 100644 starters/spring-ai-starter-model-google-genai-image/pom.xml diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-google-genai/pom.xml b/auto-configurations/models/spring-ai-autoconfigure-model-google-genai/pom.xml index 1ac21abe98..1231bbfe59 100644 --- a/auto-configurations/models/spring-ai-autoconfigure-model-google-genai/pom.xml +++ b/auto-configurations/models/spring-ai-autoconfigure-model-google-genai/pom.xml @@ -35,6 +35,14 @@ true + + + org.springframework.ai + spring-ai-google-genai-image + ${project.parent.version} + true + + @@ -61,6 +69,12 @@ ${project.parent.version} + + org.springframework.ai + spring-ai-autoconfigure-model-image-observation + ${project.parent.version} + + org.springframework.boot diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-google-genai/src/main/java/org/springframework/ai/model/google/genai/autoconfigure/image/GoogleGenAiImageAutoConfiguration.java b/auto-configurations/models/spring-ai-autoconfigure-model-google-genai/src/main/java/org/springframework/ai/model/google/genai/autoconfigure/image/GoogleGenAiImageAutoConfiguration.java new file mode 100644 index 0000000000..564b12f34b --- /dev/null +++ b/auto-configurations/models/spring-ai-autoconfigure-model-google-genai/src/main/java/org/springframework/ai/model/google/genai/autoconfigure/image/GoogleGenAiImageAutoConfiguration.java @@ -0,0 +1,65 @@ +/* + * Copyright 2023-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.model.google.genai.autoconfigure.image; + +import io.micrometer.observation.ObservationRegistry; + +import org.springframework.ai.google.genai.image.GoogleGenAiImageConnectionDetails; +import org.springframework.ai.google.genai.image.GoogleGenAiImageModel; +import org.springframework.ai.image.observation.ImageModelObservationConvention; +import org.springframework.ai.model.SpringAIModelProperties; +import org.springframework.ai.model.SpringAIModels; +import org.springframework.ai.retry.RetryUtils; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.core.retry.RetryTemplate; + +/** + * Auto-configuration for Google GenAI Image. + * + * @author Olivier Le Quellec + * @since 1.1.0 + */ +@AutoConfiguration +@ConditionalOnClass(GoogleGenAiImageModel.class) +@ConditionalOnProperty(name = SpringAIModelProperties.IMAGE_MODEL, havingValue = SpringAIModels.GOOGLE_GEN_AI, + matchIfMissing = true) +@EnableConfigurationProperties(GoogleGenAiImageProperties.class) +public class GoogleGenAiImageAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public GoogleGenAiImageModel googleGenAiImageModel(GoogleGenAiImageConnectionDetails connectionDetails, + GoogleGenAiImageProperties imageProperties, ObjectProvider retryTemplate, + ObjectProvider observationRegistry, + ObjectProvider observationConvention) { + + var imageModel = new GoogleGenAiImageModel(connectionDetails, imageProperties.toOptions(), + retryTemplate.getIfUnique(() -> RetryUtils.DEFAULT_RETRY_TEMPLATE), + observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP)); + + observationConvention.ifAvailable(imageModel::setObservationConvention); + + return imageModel; + } + +} diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-google-genai/src/main/java/org/springframework/ai/model/google/genai/autoconfigure/image/GoogleGenAiImageConnectionAutoConfiguration.java b/auto-configurations/models/spring-ai-autoconfigure-model-google-genai/src/main/java/org/springframework/ai/model/google/genai/autoconfigure/image/GoogleGenAiImageConnectionAutoConfiguration.java new file mode 100644 index 0000000000..0add9fec4d --- /dev/null +++ b/auto-configurations/models/spring-ai-autoconfigure-model-google-genai/src/main/java/org/springframework/ai/model/google/genai/autoconfigure/image/GoogleGenAiImageConnectionAutoConfiguration.java @@ -0,0 +1,74 @@ +/* + * Copyright 2023-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.model.google.genai.autoconfigure.image; + +import java.io.IOException; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.genai.Client; + +import org.springframework.ai.google.genai.image.GoogleGenAiImageConnectionDetails; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * Auto-configuration for Google GenAI Image Connection. + * + * @author Olivier Le Quellec + * @since 1.1.0 + */ +@AutoConfiguration +@ConditionalOnClass({ Client.class, GoogleGenAiImageConnectionDetails.class }) +@EnableConfigurationProperties(GoogleGenAiImageConnectionProperties.class) +public class GoogleGenAiImageConnectionAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public GoogleGenAiImageConnectionDetails googleGenAiImageConnectionDetails( + GoogleGenAiImageConnectionProperties connectionProperties) throws IOException { + + var connectionBuilder = GoogleGenAiImageConnectionDetails.builder(); + + if (StringUtils.hasText(connectionProperties.getApiKey())) { + // Gemini Developer API mode + connectionBuilder.apiKey(connectionProperties.getApiKey()); + } + else { + // Vertex AI mode + Assert.hasText(connectionProperties.getProjectId(), "Google GenAI project-id must be set!"); + Assert.hasText(connectionProperties.getLocation(), "Google GenAI location must be set!"); + + connectionBuilder.projectId(connectionProperties.getProjectId()) + .location(connectionProperties.getLocation()); + + if (connectionProperties.getCredentialsUri() != null) { + GoogleCredentials credentials = GoogleCredentials + .fromStream(connectionProperties.getCredentialsUri().getInputStream()); + // Note: Credentials are handled automatically by the SDK when using + // Vertex AI mode + } + } + + return connectionBuilder.build(); + } + +} diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-google-genai/src/main/java/org/springframework/ai/model/google/genai/autoconfigure/image/GoogleGenAiImageConnectionProperties.java b/auto-configurations/models/spring-ai-autoconfigure-model-google-genai/src/main/java/org/springframework/ai/model/google/genai/autoconfigure/image/GoogleGenAiImageConnectionProperties.java new file mode 100644 index 0000000000..aa23f1b137 --- /dev/null +++ b/auto-configurations/models/spring-ai-autoconfigure-model-google-genai/src/main/java/org/springframework/ai/model/google/genai/autoconfigure/image/GoogleGenAiImageConnectionProperties.java @@ -0,0 +1,101 @@ +/* + * Copyright 2023-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.model.google.genai.autoconfigure.image; + +import org.jspecify.annotations.Nullable; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.core.io.Resource; + +/** + * Connection properties for the Google GenAI Image. + * + * @author Olivier Le Quellec + * @since 1.1.0 + */ +@ConfigurationProperties(GoogleGenAiImageConnectionProperties.CONFIG_PREFIX) +public class GoogleGenAiImageConnectionProperties { + + public static final String CONFIG_PREFIX = "spring.ai.google.genai.image"; + + /** + * Google GenAI API Key (for Gemini Developer API mode). + */ + private @Nullable String apiKey; + + /** + * Google Cloud project ID (for Vertex AI mode). + */ + private @Nullable String projectId; + + /** + * Google Cloud location (for Vertex AI mode). + */ + private @Nullable String location; + + /** + * URI to Google Cloud credentials (optional, for Vertex AI mode). + */ + private @Nullable Resource credentialsUri; + + /** + * Whether to use Vertex AI mode. If false, uses Gemini Developer API mode. This is + * automatically determined based on whether apiKey or projectId is set. + */ + private boolean vertexAi; + + public @Nullable String getApiKey() { + return this.apiKey; + } + + public void setApiKey(@Nullable String apiKey) { + this.apiKey = apiKey; + } + + public @Nullable String getProjectId() { + return this.projectId; + } + + public void setProjectId(@Nullable String projectId) { + this.projectId = projectId; + } + + public @Nullable String getLocation() { + return this.location; + } + + public void setLocation(@Nullable String location) { + this.location = location; + } + + public @Nullable Resource getCredentialsUri() { + return this.credentialsUri; + } + + public void setCredentialsUri(@Nullable Resource credentialsUri) { + this.credentialsUri = credentialsUri; + } + + public boolean isVertexAi() { + return this.vertexAi; + } + + public void setVertexAi(boolean vertexAi) { + this.vertexAi = vertexAi; + } + +} diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-google-genai/src/main/java/org/springframework/ai/model/google/genai/autoconfigure/image/GoogleGenAiImageProperties.java b/auto-configurations/models/spring-ai-autoconfigure-model-google-genai/src/main/java/org/springframework/ai/model/google/genai/autoconfigure/image/GoogleGenAiImageProperties.java new file mode 100644 index 0000000000..e732ff70e0 --- /dev/null +++ b/auto-configurations/models/spring-ai-autoconfigure-model-google-genai/src/main/java/org/springframework/ai/model/google/genai/autoconfigure/image/GoogleGenAiImageProperties.java @@ -0,0 +1,362 @@ +/* + * Copyright 2023-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.model.google.genai.autoconfigure.image; + +import java.util.List; +import java.util.Map; + +import org.jspecify.annotations.Nullable; + +import org.springframework.ai.google.genai.image.GoogleGenAiImageOptions; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Configuration properties for Google GenAI Image. + * + * @author Olivier Le Quellec + * @since 1.1.0 + */ +@ConfigurationProperties(GoogleGenAiImageProperties.CONFIG_PREFIX) +public class GoogleGenAiImageProperties { + + public static final String CONFIG_PREFIX = "spring.ai.google.genai.image"; + + private @Nullable String model; + + private @Nullable Integer n; + + private @Nullable String negativePrompt; + + private @Nullable String aspectRatio = GoogleGenAiImageOptions.DEFAULT_ASPECT_RATIO; + + private @Nullable Float guidanceScale; + + private @Nullable Integer seed; + + private GoogleGenAiImageOptions.@Nullable SafetyFilterLevel safetyFilterLevel; + + private GoogleGenAiImageOptions.@Nullable PersonGeneration personGeneration; + + private @Nullable Boolean includeSafetyAttributes; + + private @Nullable Boolean includeRaiReason; + + private @Nullable String language; + + private @Nullable String outputMimeType; + + private @Nullable Integer outputCompressionQuality; + + private @Nullable Map labels; + + private @Nullable String imageSize; + + private @Nullable Boolean enhancePrompt; + + private @Nullable Float temperature; + + private @Nullable Float topP; + + private @Nullable Float topK; + + private @Nullable Integer maxOutputTokens; + + private @Nullable List stopSequences; + + private @Nullable Boolean responseLogprobs; + + private @Nullable Integer logprobs; + + private @Nullable Float presencePenalty; + + private @Nullable Float frequencyPenalty; + + private @Nullable String responseMimeType; + + private @Nullable String mediaResolution; + + private @Nullable String cachedContent; + + private @Nullable String serviceTier; + + public @Nullable String getModel() { + return this.model; + } + + public void setModel(@Nullable String model) { + this.model = model; + } + + public @Nullable Integer getN() { + return this.n; + } + + public void setN(@Nullable Integer n) { + this.n = n; + } + + public @Nullable String getNegativePrompt() { + return this.negativePrompt; + } + + public void setNegativePrompt(@Nullable String negativePrompt) { + this.negativePrompt = negativePrompt; + } + + public @Nullable String getAspectRatio() { + return this.aspectRatio; + } + + public void setAspectRatio(@Nullable String aspectRatio) { + this.aspectRatio = aspectRatio; + } + + public @Nullable Float getGuidanceScale() { + return this.guidanceScale; + } + + public void setGuidanceScale(@Nullable Float guidanceScale) { + this.guidanceScale = guidanceScale; + } + + public @Nullable Integer getSeed() { + return this.seed; + } + + public void setSeed(@Nullable Integer seed) { + this.seed = seed; + } + + public GoogleGenAiImageOptions.@Nullable SafetyFilterLevel getSafetyFilterLevel() { + return this.safetyFilterLevel; + } + + public void setSafetyFilterLevel(GoogleGenAiImageOptions.@Nullable SafetyFilterLevel safetyFilterLevel) { + this.safetyFilterLevel = safetyFilterLevel; + } + + public GoogleGenAiImageOptions.@Nullable PersonGeneration getPersonGeneration() { + return this.personGeneration; + } + + public void setPersonGeneration(GoogleGenAiImageOptions.@Nullable PersonGeneration personGeneration) { + this.personGeneration = personGeneration; + } + + public @Nullable Boolean getIncludeSafetyAttributes() { + return this.includeSafetyAttributes; + } + + public void setIncludeSafetyAttributes(@Nullable Boolean includeSafetyAttributes) { + this.includeSafetyAttributes = includeSafetyAttributes; + } + + public @Nullable Boolean getIncludeRaiReason() { + return this.includeRaiReason; + } + + public void setIncludeRaiReason(@Nullable Boolean includeRaiReason) { + this.includeRaiReason = includeRaiReason; + } + + public @Nullable String getLanguage() { + return this.language; + } + + public void setLanguage(@Nullable String language) { + this.language = language; + } + + public @Nullable String getOutputMimeType() { + return this.outputMimeType; + } + + public void setOutputMimeType(@Nullable String outputMimeType) { + this.outputMimeType = outputMimeType; + } + + public @Nullable Integer getOutputCompressionQuality() { + return this.outputCompressionQuality; + } + + public void setOutputCompressionQuality(@Nullable Integer outputCompressionQuality) { + this.outputCompressionQuality = outputCompressionQuality; + } + + public @Nullable Map getLabels() { + return this.labels; + } + + public void setLabels(@Nullable Map labels) { + this.labels = labels; + } + + public @Nullable String getImageSize() { + return this.imageSize; + } + + public void setImageSize(@Nullable String imageSize) { + this.imageSize = imageSize; + } + + public @Nullable Boolean getEnhancePrompt() { + return this.enhancePrompt; + } + + public void setEnhancePrompt(@Nullable Boolean enhancePrompt) { + this.enhancePrompt = enhancePrompt; + } + + public @Nullable Float getTemperature() { + return this.temperature; + } + + public void setTemperature(@Nullable Float temperature) { + this.temperature = temperature; + } + + public @Nullable Float getTopP() { + return this.topP; + } + + public void setTopP(@Nullable Float topP) { + this.topP = topP; + } + + public @Nullable Float getTopK() { + return this.topK; + } + + public void setTopK(@Nullable Float topK) { + this.topK = topK; + } + + public @Nullable Integer getMaxOutputTokens() { + return this.maxOutputTokens; + } + + public void setMaxOutputTokens(@Nullable Integer maxOutputTokens) { + this.maxOutputTokens = maxOutputTokens; + } + + public @Nullable List getStopSequences() { + return this.stopSequences; + } + + public void setStopSequences(@Nullable List stopSequences) { + this.stopSequences = stopSequences; + } + + public @Nullable Boolean getResponseLogprobs() { + return this.responseLogprobs; + } + + public void setResponseLogprobs(@Nullable Boolean responseLogprobs) { + this.responseLogprobs = responseLogprobs; + } + + public @Nullable Integer getLogprobs() { + return this.logprobs; + } + + public void setLogprobs(@Nullable Integer logprobs) { + this.logprobs = logprobs; + } + + public @Nullable Float getPresencePenalty() { + return this.presencePenalty; + } + + public void setPresencePenalty(@Nullable Float presencePenalty) { + this.presencePenalty = presencePenalty; + } + + public @Nullable Float getFrequencyPenalty() { + return this.frequencyPenalty; + } + + public void setFrequencyPenalty(@Nullable Float frequencyPenalty) { + this.frequencyPenalty = frequencyPenalty; + } + + public @Nullable String getResponseMimeType() { + return this.responseMimeType; + } + + public void setResponseMimeType(@Nullable String responseMimeType) { + this.responseMimeType = responseMimeType; + } + + public @Nullable String getMediaResolution() { + return this.mediaResolution; + } + + public void setMediaResolution(@Nullable String mediaResolution) { + this.mediaResolution = mediaResolution; + } + + public @Nullable String getCachedContent() { + return this.cachedContent; + } + + public void setCachedContent(@Nullable String cachedContent) { + this.cachedContent = cachedContent; + } + + public @Nullable String getServiceTier() { + return this.serviceTier; + } + + public void setServiceTier(@Nullable String serviceTier) { + this.serviceTier = serviceTier; + } + + public GoogleGenAiImageOptions toOptions() { + return GoogleGenAiImageOptions.builder() + .model(this.model) + .n(this.n) + .negativePrompt(this.negativePrompt) + .aspectRatio(this.aspectRatio) + .guidanceScale(this.guidanceScale) + .seed(this.seed) + .safetyFilterLevel(this.safetyFilterLevel) + .personGeneration(this.personGeneration) + .includeSafetyAttributes(this.includeSafetyAttributes) + .includeRaiReason(this.includeRaiReason) + .language(this.language) + .outputMimeType(this.outputMimeType) + .outputCompressionQuality(this.outputCompressionQuality) + .labels(this.labels) + .imageSize(this.imageSize) + .enhancePrompt(this.enhancePrompt) + .temperature(this.temperature) + .topP(this.topP) + .topK(this.topK) + .maxOutputTokens(this.maxOutputTokens) + .stopSequences(this.stopSequences) + .responseLogprobs(this.responseLogprobs) + .logprobs(this.logprobs) + .presencePenalty(this.presencePenalty) + .frequencyPenalty(this.frequencyPenalty) + .responseMimeType(this.responseMimeType) + .mediaResolution(this.mediaResolution) + .cachedContent(this.cachedContent) + .serviceTier(this.serviceTier) + .build(); + } + +} diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-google-genai/src/main/java/org/springframework/ai/model/google/genai/autoconfigure/image/package-info.java b/auto-configurations/models/spring-ai-autoconfigure-model-google-genai/src/main/java/org/springframework/ai/model/google/genai/autoconfigure/image/package-info.java new file mode 100644 index 0000000000..f38db5ec0f --- /dev/null +++ b/auto-configurations/models/spring-ai-autoconfigure-model-google-genai/src/main/java/org/springframework/ai/model/google/genai/autoconfigure/image/package-info.java @@ -0,0 +1,21 @@ +/* + * Copyright 2023-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Auto-configuration for Google GenAI Imagen image generation. + */ +@org.jspecify.annotations.NullMarked +package org.springframework.ai.model.google.genai.autoconfigure.image; diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-google-genai/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/auto-configurations/models/spring-ai-autoconfigure-model-google-genai/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index d054f706df..16304b49a7 100644 --- a/auto-configurations/models/spring-ai-autoconfigure-model-google-genai/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/auto-configurations/models/spring-ai-autoconfigure-model-google-genai/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -16,3 +16,5 @@ org.springframework.ai.model.google.genai.autoconfigure.chat.GoogleGenAiChatAutoConfiguration org.springframework.ai.model.google.genai.autoconfigure.embedding.GoogleGenAiEmbeddingConnectionAutoConfiguration org.springframework.ai.model.google.genai.autoconfigure.embedding.GoogleGenAiTextEmbeddingAutoConfiguration +org.springframework.ai.model.google.genai.autoconfigure.image.GoogleGenAiImageConnectionAutoConfiguration +org.springframework.ai.model.google.genai.autoconfigure.image.GoogleGenAiImageAutoConfiguration diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-google-genai/src/test/java/org/springframework/ai/model/google/genai/autoconfigure/image/GoogleGenAiImageAutoConfigurationIT.java b/auto-configurations/models/spring-ai-autoconfigure-model-google-genai/src/test/java/org/springframework/ai/model/google/genai/autoconfigure/image/GoogleGenAiImageAutoConfigurationIT.java new file mode 100644 index 0000000000..cb617894d1 --- /dev/null +++ b/auto-configurations/models/spring-ai-autoconfigure-model-google-genai/src/test/java/org/springframework/ai/model/google/genai/autoconfigure/image/GoogleGenAiImageAutoConfigurationIT.java @@ -0,0 +1,106 @@ +/* + * Copyright 2023-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.model.google.genai.autoconfigure.image; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +import org.springframework.ai.google.genai.image.GoogleGenAiImageModel; +import org.springframework.ai.image.ImagePrompt; +import org.springframework.ai.image.ImageResponse; +import org.springframework.ai.retry.autoconfigure.SpringAiRetryAutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for {@link GoogleGenAiImageAutoConfiguration}. + * + *

+ * Activated via either {@code GOOGLE_API_KEY} (Gemini Developer API) or + * {@code GOOGLE_CLOUD_PROJECT} + {@code GOOGLE_CLOUD_LOCATION} (Vertex AI). Tests are + * skipped when the corresponding environment variables are absent. + * + * @author Olivier Le Quellec + */ +class GoogleGenAiImageAutoConfigurationIT { + + @Test + @EnabledIfEnvironmentVariable(named = "GOOGLE_API_KEY", matches = ".+") + void imageWithApiKey() { + ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.google.genai.image.api-key=" + System.getenv("GOOGLE_API_KEY"), + "spring.ai.google.genai.image.model=gemini-2.5-flash-image", "spring.ai.google.genai.image.n=1") + .withConfiguration(AutoConfigurations.of(GoogleGenAiImageAutoConfiguration.class, + SpringAiRetryAutoConfiguration.class)); + + contextRunner.run(context -> { + GoogleGenAiImageModel imageModel = context.getBean(GoogleGenAiImageModel.class); + ImageResponse response = imageModel.call(new ImagePrompt("A simple red apple")); + assertThat(response.getResults()).isNotEmpty(); + assertThat(response.getResults().get(0).getOutput()).isNotNull(); + }); + } + + @Test + @EnabledIfEnvironmentVariable(named = "GOOGLE_CLOUD_PROJECT", matches = ".+") + @EnabledIfEnvironmentVariable(named = "GOOGLE_CLOUD_LOCATION", matches = ".+") + void imageWithVertexAi() { + ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.google.genai.image.project-id=" + System.getenv("GOOGLE_CLOUD_PROJECT"), + "spring.ai.google.genai.image.location=" + System.getenv("GOOGLE_CLOUD_LOCATION"), + "spring.ai.google.genai.image.model=gemini-2.5-flash-image", "spring.ai.google.genai.image.n=1") + .withConfiguration(AutoConfigurations.of(GoogleGenAiImageAutoConfiguration.class, + SpringAiRetryAutoConfiguration.class)); + + contextRunner.run(context -> { + GoogleGenAiImageModel imageModel = context.getBean(GoogleGenAiImageModel.class); + ImageResponse response = imageModel.call(new ImagePrompt("A simple red apple")); + assertThat(response.getResults()).isNotEmpty(); + assertThat(response.getResults().get(0).getOutput()).isNotNull(); + }); + } + + @Test + @EnabledIfEnvironmentVariable(named = "GOOGLE_API_KEY", matches = ".+") + void imageModelActivation() { + ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.google.genai.image.api-key=test-key"); + + // Test that image model is not activated when disabled + contextRunner + .withConfiguration(AutoConfigurations.of(GoogleGenAiImageAutoConfiguration.class, + GoogleGenAiImageConnectionAutoConfiguration.class)) + .withPropertyValues("spring.ai.model.image=none") + .run(context -> { + assertThat(context.getBeansOfType(GoogleGenAiImageProperties.class)).isEmpty(); + assertThat(context.getBeansOfType(GoogleGenAiImageModel.class)).isEmpty(); + }); + + // Test that image model is activated when enabled + contextRunner + .withConfiguration(AutoConfigurations.of(GoogleGenAiImageAutoConfiguration.class, + GoogleGenAiImageConnectionAutoConfiguration.class, SpringAiRetryAutoConfiguration.class)) + .withPropertyValues("spring.ai.model.image=google-genai") + .run(context -> { + assertThat(context.getBeansOfType(GoogleGenAiImageProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(GoogleGenAiImageModel.class)).isNotEmpty(); + }); + } + +} diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-google-genai/src/test/java/org/springframework/ai/model/google/genai/autoconfigure/image/GoogleGenAiImagePropertiesTests.java b/auto-configurations/models/spring-ai-autoconfigure-model-google-genai/src/test/java/org/springframework/ai/model/google/genai/autoconfigure/image/GoogleGenAiImagePropertiesTests.java new file mode 100644 index 0000000000..2650d570f6 --- /dev/null +++ b/auto-configurations/models/spring-ai-autoconfigure-model-google-genai/src/test/java/org/springframework/ai/model/google/genai/autoconfigure/image/GoogleGenAiImagePropertiesTests.java @@ -0,0 +1,130 @@ +/* + * Copyright 2023-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.model.google.genai.autoconfigure.image; + +import org.junit.jupiter.api.Test; + +import org.springframework.ai.google.genai.image.GoogleGenAiImageOptions; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Configuration; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for Google GenAI Image properties binding. + * + * @author Olivier Le Quellec + */ +class GoogleGenAiImagePropertiesTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withUserConfiguration(PropertiesTestConfiguration.class); + + @Test + void connectionPropertiesBinding() { + this.contextRunner + .withPropertyValues("spring.ai.google.genai.image.api-key=test-key", + "spring.ai.google.genai.image.project-id=test-project", + "spring.ai.google.genai.image.location=us-central1") + .run(context -> { + GoogleGenAiImageConnectionProperties props = context + .getBean(GoogleGenAiImageConnectionProperties.class); + assertThat(props.getApiKey()).isEqualTo("test-key"); + assertThat(props.getProjectId()).isEqualTo("test-project"); + assertThat(props.getLocation()).isEqualTo("us-central1"); + }); + } + + @Test + void optionsPropertiesBinding() { + this.contextRunner + .withPropertyValues("spring.ai.google.genai.image.model=imagen-4.0-ultra-generate-001", + "spring.ai.google.genai.image.n=2", "spring.ai.google.genai.image.aspect-ratio=16:9", + "spring.ai.google.genai.image.negative-prompt=blurry", + "spring.ai.google.genai.image.guidance-scale=5.0", "spring.ai.google.genai.image.seed=42", + "spring.ai.google.genai.image.safety-filter-level=BLOCK_ONLY_HIGH", + "spring.ai.google.genai.image.person-generation=ALLOW_ADULT", + "spring.ai.google.genai.image.include-safety-attributes=true", + "spring.ai.google.genai.image.include-rai-reason=true", "spring.ai.google.genai.image.language=en", + "spring.ai.google.genai.image.output-mime-type=image/png", + "spring.ai.google.genai.image.output-compression-quality=80", + "spring.ai.google.genai.image.image-size=2K", "spring.ai.google.genai.image.enhance-prompt=true", + "spring.ai.google.genai.image.labels.env=test", "spring.ai.google.genai.image.temperature=0.7", + "spring.ai.google.genai.image.top-p=0.9", "spring.ai.google.genai.image.top-k=40", + "spring.ai.google.genai.image.max-output-tokens=1024", + "spring.ai.google.genai.image.stop-sequences=STOP,END", + "spring.ai.google.genai.image.response-logprobs=true", "spring.ai.google.genai.image.logprobs=5", + "spring.ai.google.genai.image.presence-penalty=0.5", + "spring.ai.google.genai.image.frequency-penalty=0.25", + "spring.ai.google.genai.image.response-mime-type=application/json", + "spring.ai.google.genai.image.media-resolution=MEDIA_RESOLUTION_HIGH", + "spring.ai.google.genai.image.cached-content=cachedContents/abc", + "spring.ai.google.genai.image.service-tier=FLEX") + .run(context -> { + GoogleGenAiImageProperties props = context.getBean(GoogleGenAiImageProperties.class); + GoogleGenAiImageOptions options = props.toOptions(); + assertThat(options.getModel()).isEqualTo("imagen-4.0-ultra-generate-001"); + assertThat(options.getN()).isEqualTo(2); + assertThat(options.getAspectRatio()).isEqualTo("16:9"); + assertThat(options.getNegativePrompt()).isEqualTo("blurry"); + assertThat(options.getGuidanceScale()).isEqualTo(5.0f); + assertThat(options.getSeed()).isEqualTo(42); + assertThat(options.getSafetyFilterLevel()) + .isEqualTo(GoogleGenAiImageOptions.SafetyFilterLevel.BLOCK_ONLY_HIGH); + assertThat(options.getPersonGeneration()) + .isEqualTo(GoogleGenAiImageOptions.PersonGeneration.ALLOW_ADULT); + assertThat(options.getIncludeSafetyAttributes()).isTrue(); + assertThat(options.getIncludeRaiReason()).isTrue(); + assertThat(options.getLanguage()).isEqualTo("en"); + assertThat(options.getOutputMimeType()).isEqualTo("image/png"); + assertThat(options.getOutputCompressionQuality()).isEqualTo(80); + assertThat(options.getImageSize()).isEqualTo("2K"); + assertThat(options.getEnhancePrompt()).isTrue(); + assertThat(options.getLabels()).containsEntry("env", "test"); + assertThat(options.getTemperature()).isEqualTo(0.7f); + assertThat(options.getTopP()).isEqualTo(0.9f); + assertThat(options.getTopK()).isEqualTo(40.0f); + assertThat(options.getMaxOutputTokens()).isEqualTo(1024); + assertThat(options.getStopSequences()).containsExactly("STOP", "END"); + assertThat(options.getResponseLogprobs()).isTrue(); + assertThat(options.getLogprobs()).isEqualTo(5); + assertThat(options.getPresencePenalty()).isEqualTo(0.5f); + assertThat(options.getFrequencyPenalty()).isEqualTo(0.25f); + assertThat(options.getResponseMimeType()).isEqualTo("application/json"); + assertThat(options.getMediaResolution()).isEqualTo("MEDIA_RESOLUTION_HIGH"); + assertThat(options.getCachedContent()).isEqualTo("cachedContents/abc"); + assertThat(options.getServiceTier()).isEqualTo("FLEX"); + }); + } + + @Test + void defaultOptionsBinding() { + this.contextRunner.run(context -> { + GoogleGenAiImageProperties props = context.getBean(GoogleGenAiImageProperties.class); + assertThat(props.toOptions().getModel()).isEqualTo(GoogleGenAiImageOptions.DEFAULT_MODEL_NAME); + assertThat(props.toOptions().getAspectRatio()).isEqualTo(GoogleGenAiImageOptions.DEFAULT_ASPECT_RATIO); + }); + } + + @Configuration + @EnableConfigurationProperties({ GoogleGenAiImageConnectionProperties.class, GoogleGenAiImageProperties.class }) + static class PropertiesTestConfiguration { + + } + +} diff --git a/models/spring-ai-google-genai-image/pom.xml b/models/spring-ai-google-genai-image/pom.xml new file mode 100644 index 0000000000..e3acfaa411 --- /dev/null +++ b/models/spring-ai-google-genai-image/pom.xml @@ -0,0 +1,87 @@ + + + + + 4.0.0 + + org.springframework.ai + spring-ai-parent + 2.0.0-SNAPSHOT + ../../pom.xml + + spring-ai-google-genai-image + jar + Spring AI Model - Google GenAI Image + Google GenAI Imagen image generation models support + https://github.com/spring-projects/spring-ai + + + + + com.google.genai + google-genai + ${com.google.genai.version} + + + + + org.springframework.ai + spring-ai-model + ${project.parent.version} + + + + org.springframework.ai + spring-ai-retry + ${project.parent.version} + + + + + org.springframework + spring-context-support + + + + org.slf4j + slf4j-api + + + + io.micrometer + micrometer-observation-test + test + + + + + org.springframework.ai + spring-ai-test + ${project.parent.version} + test + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + diff --git a/models/spring-ai-google-genai-image/src/main/java/org/springframework/ai/google/genai/image/GoogleGenAiImageConnectionDetails.java b/models/spring-ai-google-genai-image/src/main/java/org/springframework/ai/google/genai/image/GoogleGenAiImageConnectionDetails.java new file mode 100644 index 0000000000..bf7d9d5fd8 --- /dev/null +++ b/models/spring-ai-google-genai-image/src/main/java/org/springframework/ai/google/genai/image/GoogleGenAiImageConnectionDetails.java @@ -0,0 +1,181 @@ +/* + * Copyright 2023-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.google.genai.image; + +import com.google.genai.Client; +import org.jspecify.annotations.Nullable; + +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * GoogleGenAiImageConnectionDetails represents the details of a connection to the image + * service using the new Google Gen AI SDK. It provides methods to create and configure + * the GenAI Client instance. + * + * @author Olivier Le Quellec + * @since 1.1.0 + */ +public final class GoogleGenAiImageConnectionDetails { + + public static final String DEFAULT_LOCATION = "us-central1"; + + public static final String DEFAULT_PUBLISHER = "google"; + + /** + * Your project ID. + */ + private final @Nullable String projectId; + + /** + * A location is a region + * you can specify in a request to control where data is stored at rest. For a list of + * available regions, see Generative + * AI on Vertex AI locations. + */ + private final @Nullable String location; + + /** + * The API key for using Gemini Developer API. If null, Vertex AI mode will be used. + */ + private final @Nullable String apiKey; + + /** + * The GenAI Client instance configured for this connection. + */ + private final Client genAiClient; + + private GoogleGenAiImageConnectionDetails(@Nullable String projectId, @Nullable String location, + @Nullable String apiKey, Client genAiClient) { + this.projectId = projectId; + this.location = location; + this.apiKey = apiKey; + this.genAiClient = genAiClient; + } + + public static Builder builder() { + return new Builder(); + } + + public @Nullable String getProjectId() { + return this.projectId; + } + + public @Nullable String getLocation() { + return this.location; + } + + public @Nullable String getApiKey() { + return this.apiKey; + } + + public Client getGenAiClient() { + return this.genAiClient; + } + + /** + * Constructs the model endpoint name in the format expected by the image models. + * @param modelName the model name (e.g., "gemini-2.5-flash-image") + * @return the full model endpoint name + */ + public String getModelEndpointName(String modelName) { + // For the new SDK, we just return the model name as is + // The SDK handles the full endpoint construction internally + return modelName; + } + + public static final class Builder { + + /** + * Your project ID. + */ + private @Nullable String projectId; + + /** + * A location is a + * region you can + * specify in a request to control where data is stored at rest. For a list of + * available regions, see Generative + * AI on Vertex AI locations. + */ + private @Nullable String location; + + /** + * The API key for using Gemini Developer API. If null, Vertex AI mode will be + * used. + */ + private @Nullable String apiKey; + + /** + * Custom GenAI client instance. If provided, other settings will be ignored. + */ + private @Nullable Client genAiClient; + + public Builder projectId(@Nullable String projectId) { + this.projectId = projectId; + return this; + } + + public Builder location(@Nullable String location) { + this.location = location; + return this; + } + + public Builder apiKey(@Nullable String apiKey) { + this.apiKey = apiKey; + return this; + } + + public Builder genAiClient(@Nullable Client genAiClient) { + this.genAiClient = genAiClient; + return this; + } + + public GoogleGenAiImageConnectionDetails build() { + // If a custom client is provided, use it directly + if (this.genAiClient != null) { + return new GoogleGenAiImageConnectionDetails(this.projectId, this.location, this.apiKey, + this.genAiClient); + } + + // Otherwise, build a new client + Client.Builder clientBuilder = Client.builder(); + + if (StringUtils.hasText(this.apiKey)) { + // Use Gemini Developer API mode + clientBuilder.apiKey(this.apiKey); + } + else { + // Use Vertex AI mode + Assert.hasText(this.projectId, "Project ID must be provided for Vertex AI mode"); + + if (!StringUtils.hasText(this.location)) { + this.location = DEFAULT_LOCATION; + } + + clientBuilder.project(this.projectId).location(this.location).vertexAI(true); + } + + Client builtClient = clientBuilder.build(); + return new GoogleGenAiImageConnectionDetails(this.projectId, this.location, this.apiKey, builtClient); + } + + } + +} diff --git a/models/spring-ai-google-genai-image/src/main/java/org/springframework/ai/google/genai/image/GoogleGenAiImageGenerationMetadata.java b/models/spring-ai-google-genai-image/src/main/java/org/springframework/ai/google/genai/image/GoogleGenAiImageGenerationMetadata.java new file mode 100644 index 0000000000..e70203647e --- /dev/null +++ b/models/spring-ai-google-genai-image/src/main/java/org/springframework/ai/google/genai/image/GoogleGenAiImageGenerationMetadata.java @@ -0,0 +1,90 @@ +/* + * Copyright 2023-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.google.genai.image; + +import java.util.Objects; + +import org.jspecify.annotations.Nullable; + +import org.springframework.ai.image.ImageGenerationMetadata; + +/** + * Image generation metadata returned by the Google GenAI image API. + * + * @author Olivier Le Quellec + * @since 1.1.0 + */ +public class GoogleGenAiImageGenerationMetadata implements ImageGenerationMetadata { + + private final @Nullable String enhancedPrompt; + + private final @Nullable String raiFilteredReason; + + private final @Nullable String mimeType; + + private final @Nullable String gcsUri; + + public GoogleGenAiImageGenerationMetadata(@Nullable String enhancedPrompt, @Nullable String raiFilteredReason, + @Nullable String mimeType, @Nullable String gcsUri) { + this.enhancedPrompt = enhancedPrompt; + this.raiFilteredReason = raiFilteredReason; + this.mimeType = mimeType; + this.gcsUri = gcsUri; + } + + public @Nullable String getEnhancedPrompt() { + return this.enhancedPrompt; + } + + public @Nullable String getRaiFilteredReason() { + return this.raiFilteredReason; + } + + public @Nullable String getMimeType() { + return this.mimeType; + } + + public @Nullable String getGcsUri() { + return this.gcsUri; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof GoogleGenAiImageGenerationMetadata that)) { + return false; + } + return Objects.equals(this.enhancedPrompt, that.enhancedPrompt) + && Objects.equals(this.raiFilteredReason, that.raiFilteredReason) + && Objects.equals(this.mimeType, that.mimeType) && Objects.equals(this.gcsUri, that.gcsUri); + } + + @Override + public int hashCode() { + return Objects.hash(this.enhancedPrompt, this.raiFilteredReason, this.mimeType, this.gcsUri); + } + + @Override + public String toString() { + return "GoogleGenAiImageGenerationMetadata{" + "enhancedPrompt='" + this.enhancedPrompt + '\'' + + ", raiFilteredReason='" + this.raiFilteredReason + '\'' + ", mimeType='" + this.mimeType + '\'' + + ", gcsUri='" + this.gcsUri + '\'' + '}'; + } + +} diff --git a/models/spring-ai-google-genai-image/src/main/java/org/springframework/ai/google/genai/image/GoogleGenAiImageModel.java b/models/spring-ai-google-genai-image/src/main/java/org/springframework/ai/google/genai/image/GoogleGenAiImageModel.java new file mode 100644 index 0000000000..7db2ea64df --- /dev/null +++ b/models/spring-ai-google-genai-image/src/main/java/org/springframework/ai/google/genai/image/GoogleGenAiImageModel.java @@ -0,0 +1,328 @@ +/* + * Copyright 2023-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.google.genai.image; + +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import com.google.genai.Client; +import com.google.genai.types.Blob; +import com.google.genai.types.Candidate; +import com.google.genai.types.Content; +import com.google.genai.types.GenerateContentConfig; +import com.google.genai.types.GenerateContentResponse; +import com.google.genai.types.HarmCategory; +import com.google.genai.types.ImageConfig; +import com.google.genai.types.Part; +import com.google.genai.types.SafetySetting; +import io.micrometer.observation.ObservationRegistry; +import org.jspecify.annotations.Nullable; + +import org.springframework.ai.image.Image; +import org.springframework.ai.image.ImageGeneration; +import org.springframework.ai.image.ImageMessage; +import org.springframework.ai.image.ImageModel; +import org.springframework.ai.image.ImageOptions; +import org.springframework.ai.image.ImagePrompt; +import org.springframework.ai.image.ImageResponse; +import org.springframework.ai.image.ImageResponseMetadata; +import org.springframework.ai.image.observation.DefaultImageModelObservationConvention; +import org.springframework.ai.image.observation.ImageModelObservationContext; +import org.springframework.ai.image.observation.ImageModelObservationConvention; +import org.springframework.ai.image.observation.ImageModelObservationDocumentation; +import org.springframework.ai.model.ModelOptionsUtils; +import org.springframework.ai.observation.conventions.AiProvider; +import org.springframework.ai.retry.RetryUtils; +import org.springframework.core.retry.RetryTemplate; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * A class representing an Image Model using the new Google Gen AI SDK. + * + * @author Olivier Le Quellec + * @since 1.1.0 + */ +public class GoogleGenAiImageModel implements ImageModel { + + private static final ImageModelObservationConvention DEFAULT_OBSERVATION_CONVENTION = new DefaultImageModelObservationConvention(); + + private final GoogleGenAiImageOptions options; + + private final GoogleGenAiImageConnectionDetails connectionDetails; + + private final RetryTemplate retryTemplate; + + /** + * Observation registry used for instrumentation. + */ + private final ObservationRegistry observationRegistry; + + /** + * Conventions to use for generating observations. + */ + private ImageModelObservationConvention observationConvention = DEFAULT_OBSERVATION_CONVENTION; + + /** + * The GenAI client instance. + */ + private final Client genAiClient; + + public GoogleGenAiImageModel(GoogleGenAiImageConnectionDetails connectionDetails, + GoogleGenAiImageOptions defaultImageOptions) { + this(connectionDetails, defaultImageOptions, RetryUtils.DEFAULT_RETRY_TEMPLATE); + } + + public GoogleGenAiImageModel(GoogleGenAiImageConnectionDetails connectionDetails, + GoogleGenAiImageOptions defaultImageOptions, RetryTemplate retryTemplate) { + this(connectionDetails, defaultImageOptions, retryTemplate, ObservationRegistry.NOOP); + } + + public GoogleGenAiImageModel(GoogleGenAiImageConnectionDetails connectionDetails, + GoogleGenAiImageOptions defaultImageOptions, RetryTemplate retryTemplate, + ObservationRegistry observationRegistry) { + Assert.notNull(connectionDetails, "GoogleGenAiImageConnectionDetails must not be null"); + Assert.notNull(defaultImageOptions, "GoogleGenAiImageOptions must not be null"); + Assert.notNull(retryTemplate, "retryTemplate must not be null"); + Assert.notNull(observationRegistry, "observationRegistry must not be null"); + this.options = defaultImageOptions; + this.connectionDetails = connectionDetails; + this.genAiClient = connectionDetails.getGenAiClient(); + this.retryTemplate = retryTemplate; + this.observationRegistry = observationRegistry; + } + + @Override + public ImageResponse call(ImagePrompt prompt) { + ImagePrompt imagePrompt = buildImagePrompt(prompt); + + var observationContext = ImageModelObservationContext.builder() + .imagePrompt(imagePrompt) + .provider(AiProvider.GOOGLE_GENAI_AI.value()) + .build(); + + return ImageModelObservationDocumentation.IMAGE_MODEL_OPERATION + .observation(this.observationConvention, DEFAULT_OBSERVATION_CONVENTION, () -> observationContext, + this.observationRegistry) + .observe(() -> { + GoogleGenAiImageOptions options = (GoogleGenAiImageOptions) imagePrompt.getOptions(); + Assert.notNull(options, "Options must not be null"); + String model = options.getModel(); + Assert.notNull(model, "Model must not be null"); + String modelName = this.connectionDetails.getModelEndpointName(model); + + // Build the GenerateContentConfig + GenerateContentConfig.Builder configBuilder = GenerateContentConfig.builder(); + + // Request image output from the content generation endpoint. + configBuilder.responseModalities("TEXT", "IMAGE"); + + if (Objects.nonNull(options.getN())) { + configBuilder.candidateCount(options.getN()); + } + if (Objects.nonNull(options.getSeed())) { + configBuilder.seed(options.getSeed()); + } + if (Objects.nonNull(options.getTemperature())) { + configBuilder.temperature(options.getTemperature()); + } + if (Objects.nonNull(options.getTopP())) { + configBuilder.topP(options.getTopP()); + } + if (Objects.nonNull(options.getTopK())) { + configBuilder.topK(options.getTopK()); + } + if (Objects.nonNull(options.getMaxOutputTokens())) { + configBuilder.maxOutputTokens(options.getMaxOutputTokens()); + } + if (Objects.nonNull(options.getStopSequences()) && !options.getStopSequences().isEmpty()) { + configBuilder.stopSequences(options.getStopSequences()); + } + if (Objects.nonNull(options.getResponseLogprobs())) { + configBuilder.responseLogprobs(options.getResponseLogprobs()); + } + if (Objects.nonNull(options.getLogprobs())) { + configBuilder.logprobs(options.getLogprobs()); + } + if (Objects.nonNull(options.getPresencePenalty())) { + configBuilder.presencePenalty(options.getPresencePenalty()); + } + if (Objects.nonNull(options.getFrequencyPenalty())) { + configBuilder.frequencyPenalty(options.getFrequencyPenalty()); + } + if (StringUtils.hasText(options.getResponseMimeType())) { + configBuilder.responseMimeType(options.getResponseMimeType()); + } + if (StringUtils.hasText(options.getMediaResolution())) { + configBuilder.mediaResolution(options.getMediaResolution()); + } + if (StringUtils.hasText(options.getCachedContent())) { + configBuilder.cachedContent(options.getCachedContent()); + } + if (StringUtils.hasText(options.getServiceTier())) { + configBuilder.serviceTier(options.getServiceTier()); + } + if (Objects.nonNull(options.getLabels()) && !options.getLabels().isEmpty()) { + configBuilder.labels(options.getLabels()); + } + if (Objects.nonNull(options.getSafetyFilterLevel()) && options + .getSafetyFilterLevel() != GoogleGenAiImageOptions.SafetyFilterLevel.SAFETY_FILTER_LEVEL_UNSPECIFIED) { + configBuilder.safetySettings(buildSafetySettings(options.getSafetyFilterLevel())); + } + + // Image specific options are carried by the nested ImageConfig. + ImageConfig.Builder imageConfigBuilder = ImageConfig.builder(); + boolean hasImageConfig = false; + if (StringUtils.hasText(options.getAspectRatio())) { + imageConfigBuilder.aspectRatio(options.getAspectRatio()); + hasImageConfig = true; + } + if (StringUtils.hasText(options.getImageSize())) { + imageConfigBuilder.imageSize(options.getImageSize()); + hasImageConfig = true; + } + if (Objects.nonNull(options.getPersonGeneration())) { + imageConfigBuilder.personGeneration(options.getPersonGeneration().name()); + hasImageConfig = true; + } + if (StringUtils.hasText(options.getOutputMimeType())) { + imageConfigBuilder.outputMimeType(options.getOutputMimeType()); + hasImageConfig = true; + } + if (Objects.nonNull(options.getOutputCompressionQuality())) { + imageConfigBuilder.outputCompressionQuality(options.getOutputCompressionQuality()); + hasImageConfig = true; + } + if (hasImageConfig) { + configBuilder.imageConfig(imageConfigBuilder.build()); + } + + GenerateContentConfig config = configBuilder.build(); + + // Convert instructions to single prompt for image + + final String promptText = prompt.getInstructions() + .stream() + .map(ImageMessage::getText) + .filter(StringUtils::hasText) + .reduce((first, second) -> first + "\n" + second) + .orElseThrow(() -> new IllegalArgumentException( + "ImagePrompt must contain at least one non-empty message")); + + GenerateContentResponse imagesResponse = RetryUtils.execute(this.retryTemplate, + () -> this.genAiClient.models.generateContent(modelName, promptText, config)); + + // Process the response: each candidate may contain multiple content + // parts, so add an ImageGeneration for every part that carries image + // data. + final List generationList = new ArrayList<>(); + if (imagesResponse.candidates().isPresent()) { + for (Candidate candidate : imagesResponse.candidates().get()) { + Optional content = candidate.content(); + if (content.isEmpty() || content.get().parts().isEmpty()) { + continue; + } + + for (Part part : content.get().parts().get()) { + Optional inlineData = part.inlineData(); + if (inlineData.isEmpty()) { + continue; + } + + Blob blob = inlineData.get(); + String b64Json = blob.data() + .map(imageBytes -> Base64.getEncoder().encodeToString(imageBytes)) + .orElse(null); + + Image image = new Image(null, b64Json); + + GoogleGenAiImageGenerationMetadata metadata = new GoogleGenAiImageGenerationMetadata(null, + null, blob.mimeType().orElse(null), image.getUrl()); + + generationList.add(new ImageGeneration(image, metadata)); + } + } + } + + ImageResponse response = new ImageResponse(generationList, new ImageResponseMetadata()); + + observationContext.setResponse(response); + + return response; + }); + } + + ImagePrompt buildImagePrompt(ImagePrompt imagePrompt) { + @Nullable ImageOptions requestOptions = imagePrompt.getOptions(); + GoogleGenAiImageOptions mergedOptions = this.options; + + if (Objects.nonNull(requestOptions)) { + GoogleGenAiImageOptions.Builder builder = GoogleGenAiImageOptions.builder() + .from(this.options) + .model(ModelOptionsUtils.mergeOption(requestOptions.getModel(), this.options.getModel())) + .n(ModelOptionsUtils.mergeOption(requestOptions.getN(), this.options.getN())) + .outputMimeType(ModelOptionsUtils.mergeOption(requestOptions.getResponseFormat(), + this.options.getResponseFormat())); + + if (requestOptions instanceof GoogleGenAiImageOptions googleOptions) { + builder.from(googleOptions); + } + + mergedOptions = builder.build(); + } + + // Validate request options + if (!StringUtils.hasText(mergedOptions.getModel())) { + throw new IllegalArgumentException("model cannot be null or empty"); + } + + return new ImagePrompt(imagePrompt.getInstructions(), mergedOptions); + } + + /** + * Applies the configured {@link GoogleGenAiImageOptions.SafetyFilterLevel} as a + * {@link SafetySetting} threshold across the harm categories supported by the + * {@code generateContent} API. The enum names of {@code SafetyFilterLevel} map + * directly onto the SDK {@code HarmBlockThreshold} values. + * @param safetyFilterLevel the configured safety filter level + * @return the list of safety settings to apply to the request + */ + private List buildSafetySettings(GoogleGenAiImageOptions.SafetyFilterLevel safetyFilterLevel) { + String threshold = safetyFilterLevel.name(); + List categories = List.of(HarmCategory.Known.HARM_CATEGORY_HARASSMENT, + HarmCategory.Known.HARM_CATEGORY_HATE_SPEECH, HarmCategory.Known.HARM_CATEGORY_SEXUALLY_EXPLICIT, + HarmCategory.Known.HARM_CATEGORY_DANGEROUS_CONTENT); + List safetySettings = new ArrayList<>(); + for (HarmCategory.Known category : categories) { + safetySettings.add(SafetySetting.builder().category(category).threshold(threshold).build()); + } + return safetySettings; + } + + /** + * Use the provided convention for reporting observation data + * @param observationConvention The provided convention + */ + public void setObservationConvention(@Nullable ImageModelObservationConvention observationConvention) { + Assert.notNull(observationConvention, "observationConvention cannot be null"); + this.observationConvention = observationConvention; + } + +} diff --git a/models/spring-ai-google-genai-image/src/main/java/org/springframework/ai/google/genai/image/GoogleGenAiImageModelName.java b/models/spring-ai-google-genai-image/src/main/java/org/springframework/ai/google/genai/image/GoogleGenAiImageModelName.java new file mode 100644 index 0000000000..0ec15920ab --- /dev/null +++ b/models/spring-ai-google-genai-image/src/main/java/org/springframework/ai/google/genai/image/GoogleGenAiImageModelName.java @@ -0,0 +1,54 @@ +/* + * Copyright 2023-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.google.genai.image; + +import org.springframework.ai.model.ModelDescription; + +/** + * Known Google GenAI Imagen image generation model names. + * + * @author Olivier Le Quellec + * @since 1.1.0 + */ +public enum GoogleGenAiImageModelName implements ModelDescription { + + GEMINI_2_5_FLASH_IMAGE("gemini-2.5-flash-image", "Nano Banana"), + + GEMINI_3_PRO_IMAGE("gemini-3-pro-image", "Nano Banana Pro"), + + GEMINI_3_1_FLASH_IMAGE("gemini-3.1-flash-image", "Nano Banana 2"); + + private final String modelName; + + private final String description; + + GoogleGenAiImageModelName(String value, String description) { + this.modelName = value; + this.description = description; + } + + @Override + public String getName() { + return this.modelName; + } + + @Override + public String getDescription() { + return this.description; + } + +} diff --git a/models/spring-ai-google-genai-image/src/main/java/org/springframework/ai/google/genai/image/GoogleGenAiImageOptions.java b/models/spring-ai-google-genai-image/src/main/java/org/springframework/ai/google/genai/image/GoogleGenAiImageOptions.java new file mode 100644 index 0000000000..910e235b65 --- /dev/null +++ b/models/spring-ai-google-genai-image/src/main/java/org/springframework/ai/google/genai/image/GoogleGenAiImageOptions.java @@ -0,0 +1,753 @@ +/* + * Copyright 2023-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.google.genai.image; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import org.jspecify.annotations.Nullable; + +import org.springframework.ai.image.ImageOptions; +import org.springframework.util.StringUtils; + +/** + * Options for the Image supported by the GenAI SDK + * + * @author Olivier Le Quellec + * @since 1.1.0 + */ +public class GoogleGenAiImageOptions implements ImageOptions { + + public static final String DEFAULT_MODEL_NAME = GoogleGenAiImageModelName.GEMINI_2_5_FLASH_IMAGE.getName(); + + public static final String DEFAULT_ASPECT_RATIO = "1:1"; + + // @formatter:off + + /** + * The model to use. + */ + private final @Nullable String model; + + /** + * Number of images to generate. Must be between 1 and 4. + */ + private final @Nullable Integer n; + + /** + * A description of what to discourage in the generated images. + */ + private final @Nullable String negativePrompt; + + /** + * Aspect ratio of the generated images. Supported values: 1:1, 3:4, 4:3, 9:16, 16:9. + */ + private final @Nullable String aspectRatio; + + /** + * Controls how much the model adheres to the text prompt. + */ + private final @Nullable Float guidanceScale; + + /** + * Random seed for image generation. + */ + private final @Nullable Integer seed; + + /** + * Filter level for safety filtering. + */ + private final @Nullable SafetyFilterLevel safetyFilterLevel; + + /** + * Allows generation of people by the model. + */ + private final @Nullable PersonGeneration personGeneration; + + /** + * Whether to report the safety scores of each generated image in the response. + */ + private final @Nullable Boolean includeSafetyAttributes; + + /** + * Whether to include the Responsible AI filter reason if the image is filtered out. + */ + private final @Nullable Boolean includeRaiReason; + + /** + * Language of the text in the prompt. + */ + private final @Nullable String language; + + /** + * MIME type of the generated image (e.g. {@code image/png}, {@code image/jpeg}). + */ + private final @Nullable String outputMimeType; + + /** + * Compression quality of the generated image (for {@code image/jpeg} only). + */ + private final @Nullable Integer outputCompressionQuality; + + /** + * User specified labels to track billing usage. + */ + private final @Nullable Map labels; + + /** + * The size of the largest dimension of the generated image. Supported: {@code 1K}, + * {@code 2K}. + */ + private final @Nullable String imageSize; + + /** + * Whether to use the prompt rewriting logic. + */ + private final @Nullable Boolean enhancePrompt; + + /** + * Controls the degree of randomness in token selection. Lower temperatures are good + * for prompts that require a less open-ended or creative response, while higher + * temperatures can lead to more diverse or creative results. + */ + private final @Nullable Float temperature; + + /** + * Tokens are selected from the most to least probable until the sum of their + * probabilities equals this value. + */ + private final @Nullable Float topP; + + /** + * For each token selection step, the {@code topK} tokens with the highest + * probabilities are sampled. + */ + private final @Nullable Float topK; + + /** + * Maximum number of tokens that can be generated in the response. + */ + private final @Nullable Integer maxOutputTokens; + + /** + * List of strings that tells the model to stop generating text if one of the strings + * is encountered in the response. + */ + private final @Nullable List stopSequences; + + /** + * Whether to return the log probabilities of the tokens that were chosen by the model + * at each step. + */ + private final @Nullable Boolean responseLogprobs; + + /** + * Number of top candidate tokens to return the log probabilities for at each + * generation step. + */ + private final @Nullable Integer logprobs; + + /** + * Positive values penalize tokens that already appear in the generated text, + * increasing the probability of generating more diverse content. + */ + private final @Nullable Float presencePenalty; + + /** + * Positive values penalize tokens that repeatedly appear in the generated text, + * increasing the probability of generating more diverse content. + */ + private final @Nullable Float frequencyPenalty; + + /** + * Output response mime type of the generated candidate text (e.g. {@code text/plain}, + * {@code application/json}). + */ + private final @Nullable String responseMimeType; + + /** + * The media resolution to use, if specified. + */ + private final @Nullable String mediaResolution; + + /** + * Resource name of a context cache that can be used in subsequent requests. + */ + private final @Nullable String cachedContent; + + /** + * The service tier to use for the request. + */ + private final @Nullable String serviceTier; + + protected GoogleGenAiImageOptions( + @Nullable String model, + @Nullable Integer n, + @Nullable String negativePrompt, + @Nullable String aspectRatio, + @Nullable Float guidanceScale, + @Nullable Integer seed, + @Nullable SafetyFilterLevel safetyFilterLevel, + @Nullable PersonGeneration personGeneration, + @Nullable Boolean includeSafetyAttributes, + @Nullable Boolean includeRaiReason, + @Nullable String language, + @Nullable String outputMimeType, + @Nullable Integer outputCompressionQuality, + @Nullable Map labels, + @Nullable String imageSize, + @Nullable Boolean enhancePrompt, + @Nullable Float temperature, + @Nullable Float topP, + @Nullable Float topK, + @Nullable Integer maxOutputTokens, + @Nullable List stopSequences, + @Nullable Boolean responseLogprobs, + @Nullable Integer logprobs, + @Nullable Float presencePenalty, + @Nullable Float frequencyPenalty, + @Nullable String responseMimeType, + @Nullable String mediaResolution, + @Nullable String cachedContent, + @Nullable String serviceTier) { + this.model = (model != null ? model : DEFAULT_MODEL_NAME); + this.n = n; + this.negativePrompt = negativePrompt; + this.aspectRatio = aspectRatio; + this.guidanceScale = guidanceScale; + this.seed = seed; + this.safetyFilterLevel = safetyFilterLevel; + this.personGeneration = personGeneration; + this.includeSafetyAttributes = includeSafetyAttributes; + this.includeRaiReason = includeRaiReason; + this.language = language; + this.outputMimeType = outputMimeType; + this.outputCompressionQuality = outputCompressionQuality; + this.labels = (labels == null) ? null : new LinkedHashMap<>(labels); + this.imageSize = imageSize; + this.enhancePrompt = enhancePrompt; + this.temperature = temperature; + this.topP = topP; + this.topK = topK; + this.maxOutputTokens = maxOutputTokens; + this.stopSequences = (stopSequences == null) ? null : new ArrayList<>(stopSequences); + this.responseLogprobs = responseLogprobs; + this.logprobs = logprobs; + this.presencePenalty = presencePenalty; + this.frequencyPenalty = frequencyPenalty; + this.responseMimeType = responseMimeType; + this.mediaResolution = mediaResolution; + this.cachedContent = cachedContent; + this.serviceTier = serviceTier; + } + + public static GoogleGenAiImageOptions.Builder builder() { + return new Builder(); + } + + + // @formatter:on + + @Override + public @Nullable String getModel() { + return this.model; + } + + @Override + public @Nullable Integer getN() { + return this.n; + } + + /** + * Image width is not directly configurable Use {@link #getAspectRatio()} or + * {@link #getImageSize()} instead. + * @return always {@code null} + */ + @Override + public @Nullable Integer getWidth() { + return null; + } + + /** + * Image height is not directly configurable Use {@link #getAspectRatio()} or + * {@link #getImageSize()} instead. + * @return always {@code null} + */ + @Override + public @Nullable Integer getHeight() { + return null; + } + + @Override + public @Nullable String getResponseFormat() { + return this.outputMimeType; + } + + @Override + public @Nullable String getStyle() { + return null; + } + + public @Nullable String getNegativePrompt() { + return this.negativePrompt; + } + + public @Nullable String getAspectRatio() { + return this.aspectRatio; + } + + public @Nullable Float getGuidanceScale() { + return this.guidanceScale; + } + + public @Nullable Integer getSeed() { + return this.seed; + } + + public @Nullable SafetyFilterLevel getSafetyFilterLevel() { + return this.safetyFilterLevel; + } + + public @Nullable PersonGeneration getPersonGeneration() { + return this.personGeneration; + } + + public @Nullable Boolean getIncludeSafetyAttributes() { + return this.includeSafetyAttributes; + } + + public @Nullable Boolean getIncludeRaiReason() { + return this.includeRaiReason; + } + + public @Nullable String getLanguage() { + return this.language; + } + + public @Nullable String getOutputMimeType() { + return this.outputMimeType; + } + + public @Nullable Integer getOutputCompressionQuality() { + return this.outputCompressionQuality; + } + + public @Nullable Map getLabels() { + return this.labels; + } + + public @Nullable String getImageSize() { + return this.imageSize; + } + + public @Nullable Boolean getEnhancePrompt() { + return this.enhancePrompt; + } + + public @Nullable Float getTemperature() { + return this.temperature; + } + + public @Nullable Float getTopP() { + return this.topP; + } + + public @Nullable Float getTopK() { + return this.topK; + } + + public @Nullable Integer getMaxOutputTokens() { + return this.maxOutputTokens; + } + + public @Nullable List getStopSequences() { + return this.stopSequences; + } + + public @Nullable Boolean getResponseLogprobs() { + return this.responseLogprobs; + } + + public @Nullable Integer getLogprobs() { + return this.logprobs; + } + + public @Nullable Float getPresencePenalty() { + return this.presencePenalty; + } + + public @Nullable Float getFrequencyPenalty() { + return this.frequencyPenalty; + } + + public @Nullable String getResponseMimeType() { + return this.responseMimeType; + } + + public @Nullable String getMediaResolution() { + return this.mediaResolution; + } + + public @Nullable String getCachedContent() { + return this.cachedContent; + } + + public @Nullable String getServiceTier() { + return this.serviceTier; + } + + /** + * Safety filter level for image generation. + */ + public enum SafetyFilterLevel { + + BLOCK_LOW_AND_ABOVE, BLOCK_MEDIUM_AND_ABOVE, BLOCK_ONLY_HIGH, BLOCK_NONE, SAFETY_FILTER_LEVEL_UNSPECIFIED + + } + + /** + * Person generation policy. + */ + public enum PersonGeneration { + + DONT_ALLOW, ALLOW_ADULT, ALLOW_ALL, PERSON_GENERATION_UNSPECIFIED + + } + + public static final class Builder { + + private @Nullable String model; + + private @Nullable Integer n; + + private @Nullable String negativePrompt; + + private @Nullable String aspectRatio; + + private @Nullable Float guidanceScale; + + private @Nullable Integer seed; + + private @Nullable SafetyFilterLevel safetyFilterLevel; + + private @Nullable PersonGeneration personGeneration; + + private @Nullable Boolean includeSafetyAttributes; + + private @Nullable Boolean includeRaiReason; + + private @Nullable String language; + + private @Nullable String outputMimeType; + + private @Nullable Integer outputCompressionQuality; + + private @Nullable Map labels; + + private @Nullable String imageSize; + + private @Nullable Boolean enhancePrompt; + + private @Nullable Float temperature; + + private @Nullable Float topP; + + private @Nullable Float topK; + + private @Nullable Integer maxOutputTokens; + + private @Nullable List stopSequences; + + private @Nullable Boolean responseLogprobs; + + private @Nullable Integer logprobs; + + private @Nullable Float presencePenalty; + + private @Nullable Float frequencyPenalty; + + private @Nullable String responseMimeType; + + private @Nullable String mediaResolution; + + private @Nullable String cachedContent; + + private @Nullable String serviceTier; + + public Builder() { + } + + public Builder from(GoogleGenAiImageOptions fromOptions) { + if (StringUtils.hasText(fromOptions.getModel())) { + this.model = fromOptions.getModel(); + } + if (Objects.nonNull(fromOptions.getN())) { + this.n = fromOptions.getN(); + } + if (StringUtils.hasText(fromOptions.getNegativePrompt())) { + this.negativePrompt = fromOptions.getNegativePrompt(); + } + if (StringUtils.hasText(fromOptions.getAspectRatio())) { + this.aspectRatio = fromOptions.getAspectRatio(); + } + if (Objects.nonNull(fromOptions.getGuidanceScale())) { + this.guidanceScale = fromOptions.getGuidanceScale(); + } + if (Objects.nonNull(fromOptions.getSeed())) { + this.seed = fromOptions.getSeed(); + } + if (Objects.nonNull(fromOptions.getSafetyFilterLevel())) { + this.safetyFilterLevel = fromOptions.getSafetyFilterLevel(); + } + if (Objects.nonNull(fromOptions.getPersonGeneration())) { + this.personGeneration = fromOptions.getPersonGeneration(); + } + if (Objects.nonNull(fromOptions.getIncludeSafetyAttributes())) { + this.includeSafetyAttributes = fromOptions.getIncludeSafetyAttributes(); + } + if (Objects.nonNull(fromOptions.getIncludeRaiReason())) { + this.includeRaiReason = fromOptions.getIncludeRaiReason(); + } + if (StringUtils.hasText(fromOptions.getLanguage())) { + this.language = fromOptions.getLanguage(); + } + if (StringUtils.hasText(fromOptions.getOutputMimeType())) { + this.outputMimeType = fromOptions.getOutputMimeType(); + } + if (Objects.nonNull(fromOptions.getOutputCompressionQuality())) { + this.outputCompressionQuality = fromOptions.getOutputCompressionQuality(); + } + if (Objects.nonNull(fromOptions.getLabels())) { + this.labels = fromOptions.getLabels(); + } + if (StringUtils.hasText(fromOptions.getImageSize())) { + this.imageSize = fromOptions.getImageSize(); + } + + if (Objects.nonNull(fromOptions.getEnhancePrompt())) { + this.enhancePrompt = fromOptions.getEnhancePrompt(); + } + + if (Objects.nonNull(fromOptions.getTemperature())) { + this.temperature = fromOptions.getTemperature(); + } + if (Objects.nonNull(fromOptions.getTopP())) { + this.topP = fromOptions.getTopP(); + } + if (Objects.nonNull(fromOptions.getTopK())) { + this.topK = fromOptions.getTopK(); + } + if (Objects.nonNull(fromOptions.getMaxOutputTokens())) { + this.maxOutputTokens = fromOptions.getMaxOutputTokens(); + } + if (Objects.nonNull(fromOptions.getStopSequences())) { + this.stopSequences = fromOptions.getStopSequences(); + } + if (Objects.nonNull(fromOptions.getResponseLogprobs())) { + this.responseLogprobs = fromOptions.getResponseLogprobs(); + } + if (Objects.nonNull(fromOptions.getLogprobs())) { + this.logprobs = fromOptions.getLogprobs(); + } + if (Objects.nonNull(fromOptions.getPresencePenalty())) { + this.presencePenalty = fromOptions.getPresencePenalty(); + } + if (Objects.nonNull(fromOptions.getFrequencyPenalty())) { + this.frequencyPenalty = fromOptions.getFrequencyPenalty(); + } + if (StringUtils.hasText(fromOptions.getResponseMimeType())) { + this.responseMimeType = fromOptions.getResponseMimeType(); + } + if (StringUtils.hasText(fromOptions.getMediaResolution())) { + this.mediaResolution = fromOptions.getMediaResolution(); + } + if (StringUtils.hasText(fromOptions.getCachedContent())) { + this.cachedContent = fromOptions.getCachedContent(); + } + if (StringUtils.hasText(fromOptions.getServiceTier())) { + this.serviceTier = fromOptions.getServiceTier(); + } + + return this; + } + + public Builder model(@Nullable String model) { + this.model = model; + return this; + } + + public Builder model(GoogleGenAiImageModelName model) { + this.model = model.getName(); + return this; + } + + public Builder n(@Nullable Integer n) { + this.n = n; + return this; + } + + public Builder negativePrompt(@Nullable String negativePrompt) { + this.negativePrompt = negativePrompt; + return this; + } + + public Builder aspectRatio(@Nullable String aspectRatio) { + this.aspectRatio = aspectRatio; + return this; + } + + public Builder guidanceScale(@Nullable Float guidanceScale) { + this.guidanceScale = guidanceScale; + return this; + } + + public Builder seed(@Nullable Integer seed) { + this.seed = seed; + return this; + } + + public Builder safetyFilterLevel(@Nullable SafetyFilterLevel safetyFilterLevel) { + this.safetyFilterLevel = safetyFilterLevel; + return this; + } + + public Builder personGeneration(@Nullable PersonGeneration personGeneration) { + this.personGeneration = personGeneration; + return this; + } + + public Builder includeSafetyAttributes(@Nullable Boolean includeSafetyAttributes) { + this.includeSafetyAttributes = includeSafetyAttributes; + return this; + } + + public Builder includeRaiReason(@Nullable Boolean includeRaiReason) { + this.includeRaiReason = includeRaiReason; + return this; + } + + public Builder language(@Nullable String language) { + this.language = language; + return this; + } + + public Builder outputMimeType(@Nullable String outputMimeType) { + this.outputMimeType = outputMimeType; + return this; + } + + public Builder outputCompressionQuality(@Nullable Integer outputCompressionQuality) { + this.outputCompressionQuality = outputCompressionQuality; + return this; + } + + public Builder labels(@Nullable Map labels) { + this.labels = labels; + return this; + } + + public Builder imageSize(@Nullable String imageSize) { + this.imageSize = imageSize; + return this; + } + + public Builder enhancePrompt(@Nullable Boolean enhancePrompt) { + this.enhancePrompt = enhancePrompt; + return this; + } + + public Builder temperature(@Nullable Float temperature) { + this.temperature = temperature; + return this; + } + + public Builder topP(@Nullable Float topP) { + this.topP = topP; + return this; + } + + public Builder topK(@Nullable Float topK) { + this.topK = topK; + return this; + } + + public Builder maxOutputTokens(@Nullable Integer maxOutputTokens) { + this.maxOutputTokens = maxOutputTokens; + return this; + } + + public Builder stopSequences(@Nullable List stopSequences) { + this.stopSequences = stopSequences; + return this; + } + + public Builder responseLogprobs(@Nullable Boolean responseLogprobs) { + this.responseLogprobs = responseLogprobs; + return this; + } + + public Builder logprobs(@Nullable Integer logprobs) { + this.logprobs = logprobs; + return this; + } + + public Builder presencePenalty(@Nullable Float presencePenalty) { + this.presencePenalty = presencePenalty; + return this; + } + + public Builder frequencyPenalty(@Nullable Float frequencyPenalty) { + this.frequencyPenalty = frequencyPenalty; + return this; + } + + public Builder responseMimeType(@Nullable String responseMimeType) { + this.responseMimeType = responseMimeType; + return this; + } + + public Builder mediaResolution(@Nullable String mediaResolution) { + this.mediaResolution = mediaResolution; + return this; + } + + public Builder cachedContent(@Nullable String cachedContent) { + this.cachedContent = cachedContent; + return this; + } + + public Builder serviceTier(@Nullable String serviceTier) { + this.serviceTier = serviceTier; + return this; + } + + public GoogleGenAiImageOptions build() { + return new GoogleGenAiImageOptions(this.model, this.n, this.negativePrompt, this.aspectRatio, + this.guidanceScale, this.seed, this.safetyFilterLevel, this.personGeneration, + this.includeSafetyAttributes, this.includeRaiReason, this.language, this.outputMimeType, + this.outputCompressionQuality, this.labels, this.imageSize, this.enhancePrompt, this.temperature, + this.topP, this.topK, this.maxOutputTokens, this.stopSequences, this.responseLogprobs, + this.logprobs, this.presencePenalty, this.frequencyPenalty, this.responseMimeType, + this.mediaResolution, this.cachedContent, this.serviceTier); + } + + } + +} diff --git a/models/spring-ai-google-genai-image/src/main/java/org/springframework/ai/google/genai/image/package-info.java b/models/spring-ai-google-genai-image/src/main/java/org/springframework/ai/google/genai/image/package-info.java new file mode 100644 index 0000000000..3bf5339d8a --- /dev/null +++ b/models/spring-ai-google-genai-image/src/main/java/org/springframework/ai/google/genai/image/package-info.java @@ -0,0 +1,21 @@ +/* + * Copyright 2023-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Google GenAI Imagen image generation support for Spring AI. + */ +@org.jspecify.annotations.NullMarked +package org.springframework.ai.google.genai.image; diff --git a/models/spring-ai-google-genai-image/src/test/java/org/springframework/ai/google/genai/image/GoogleGenAiImageModelIT.java b/models/spring-ai-google-genai-image/src/test/java/org/springframework/ai/google/genai/image/GoogleGenAiImageModelIT.java new file mode 100644 index 0000000000..73b014ba46 --- /dev/null +++ b/models/spring-ai-google-genai-image/src/test/java/org/springframework/ai/google/genai/image/GoogleGenAiImageModelIT.java @@ -0,0 +1,96 @@ +/* + * Copyright 2023-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.google.genai.image; + +import com.google.genai.Client; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import org.springframework.ai.image.ImageGeneration; +import org.springframework.ai.image.ImagePrompt; +import org.springframework.ai.image.ImageResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for image models {@link GoogleGenAiImageModel}. + * + * @author Olivier Le Quellec + */ +@SpringBootTest(classes = GoogleGenAiImageModelIT.Config.class) +@EnabledIfEnvironmentVariable(named = "GOOGLE_CLOUD_PROJECT", matches = ".+") +@EnabledIfEnvironmentVariable(named = "GOOGLE_CLOUD_LOCATION", matches = ".+") +class GoogleGenAiImageModelIT { + + @Autowired + private GoogleGenAiImageModel imageModel; + + @Autowired + private Client genAiClient; + + @ParameterizedTest(name = "{0} : {displayName} ") + @ValueSource(strings = { "gemini-2.5-flash-image" }) + void defaultImage(String modelName) { + assertThat(this.imageModel).isNotNull(); + + var options = GoogleGenAiImageOptions.builder().model(modelName).n(1).build(); + + ImagePrompt imagePrompt = new ImagePrompt("A light cream colored mini golden doodle dog", options); + + ImageResponse imageResponse = this.imageModel.call(imagePrompt); + + assertThat(imageResponse.getResults()).hasSize(1); + + ImageGeneration imageGeneration = imageResponse.getResults().get(0); + assertThat(imageGeneration.getOutput()).isNotNull(); + assertThat(imageGeneration.getOutput().getB64Json()).isNotBlank(); + } + + @SpringBootConfiguration + static class Config { + + @Bean + public GoogleGenAiImageConnectionDetails connectionDetails() { + return GoogleGenAiImageConnectionDetails.builder() + .projectId(System.getenv("GOOGLE_CLOUD_PROJECT")) + .location(System.getenv("GOOGLE_CLOUD_LOCATION")) + .build(); + } + + @Bean + public Client genAiClient(GoogleGenAiImageConnectionDetails connectionDetails) { + return connectionDetails.getGenAiClient(); + } + + @Bean + public GoogleGenAiImageModel googleGenAiImageModel(GoogleGenAiImageConnectionDetails connectionDetails) { + + GoogleGenAiImageOptions options = GoogleGenAiImageOptions.builder() + .model(GoogleGenAiImageModelName.GEMINI_2_5_FLASH_IMAGE.getName()) + .build(); + + return new GoogleGenAiImageModel(connectionDetails, options); + } + + } + +} diff --git a/models/spring-ai-google-genai-image/src/test/java/org/springframework/ai/google/genai/image/GoogleGenAiImageModelObservationIT.java b/models/spring-ai-google-genai-image/src/test/java/org/springframework/ai/google/genai/image/GoogleGenAiImageModelObservationIT.java new file mode 100644 index 0000000000..4a39426d65 --- /dev/null +++ b/models/spring-ai-google-genai-image/src/test/java/org/springframework/ai/google/genai/image/GoogleGenAiImageModelObservationIT.java @@ -0,0 +1,119 @@ +/* + * Copyright 2023-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.google.genai.image; + +import io.micrometer.observation.ObservationRegistry; +import io.micrometer.observation.tck.TestObservationRegistry; +import io.micrometer.observation.tck.TestObservationRegistryAssert; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +import org.springframework.ai.image.ImagePrompt; +import org.springframework.ai.image.ImageResponse; +import org.springframework.ai.image.observation.DefaultImageModelObservationConvention; +import org.springframework.ai.image.observation.ImageModelObservationDocumentation.LowCardinalityKeyNames; +import org.springframework.ai.observation.conventions.AiOperationType; +import org.springframework.ai.observation.conventions.AiProvider; +import org.springframework.ai.retry.RetryUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for observation instrumentation in {@link GoogleGenAiImageModel}. + * + * @author Olivier Le Quellec + */ +@SpringBootTest(classes = GoogleGenAiImageModelObservationIT.Config.class) +@EnabledIfEnvironmentVariable(named = "GOOGLE_CLOUD_PROJECT", matches = ".+") +@EnabledIfEnvironmentVariable(named = "GOOGLE_CLOUD_LOCATION", matches = ".+") +public class GoogleGenAiImageModelObservationIT { + + @Autowired + TestObservationRegistry observationRegistry; + + @Autowired + GoogleGenAiImageModel imageModel; + + @BeforeEach + void setUp() { + this.observationRegistry.clear(); + } + + @Test + void observationForImageOperation() { + + var options = GoogleGenAiImageOptions.builder() + .model(GoogleGenAiImageModelName.GEMINI_2_5_FLASH_IMAGE.getName()) + .n(1) + .build(); + + ImagePrompt imagePrompt = new ImagePrompt("A light cream colored mini golden doodle dog", options); + + ImageResponse imageResponse = this.imageModel.call(imagePrompt); + assertThat(imageResponse.getResults()).isNotEmpty(); + + TestObservationRegistryAssert.assertThat(this.observationRegistry) + .doesNotHaveAnyRemainingCurrentObservation() + .hasObservationWithNameEqualTo(DefaultImageModelObservationConvention.DEFAULT_NAME) + .that() + .hasContextualNameEqualTo("image " + GoogleGenAiImageModelName.GEMINI_2_5_FLASH_IMAGE.getName()) + .hasLowCardinalityKeyValue(LowCardinalityKeyNames.AI_OPERATION_TYPE.asString(), + AiOperationType.IMAGE.value()) + .hasLowCardinalityKeyValue(LowCardinalityKeyNames.AI_PROVIDER.asString(), + AiProvider.GOOGLE_GENAI_AI.value()) + .hasLowCardinalityKeyValue(LowCardinalityKeyNames.REQUEST_MODEL.asString(), + GoogleGenAiImageModelName.GEMINI_2_5_FLASH_IMAGE.getName()) + .hasBeenStarted() + .hasBeenStopped(); + } + + @SpringBootConfiguration + static class Config { + + @Bean + public TestObservationRegistry observationRegistry() { + return TestObservationRegistry.create(); + } + + @Bean + public GoogleGenAiImageConnectionDetails connectionDetails() { + return GoogleGenAiImageConnectionDetails.builder() + .projectId(System.getenv("GOOGLE_CLOUD_PROJECT")) + .location(System.getenv("GOOGLE_CLOUD_LOCATION")) + .build(); + } + + @Bean + public GoogleGenAiImageModel googleGenAiImageModel(GoogleGenAiImageConnectionDetails connectionDetails, + ObservationRegistry observationRegistry) { + + GoogleGenAiImageOptions options = GoogleGenAiImageOptions.builder() + .model(GoogleGenAiImageOptions.DEFAULT_MODEL_NAME) + .build(); + + return new GoogleGenAiImageModel(connectionDetails, options, RetryUtils.DEFAULT_RETRY_TEMPLATE, + observationRegistry); + } + + } + +} diff --git a/models/spring-ai-google-genai-image/src/test/java/org/springframework/ai/google/genai/image/GoogleGenAiImageRetryTests.java b/models/spring-ai-google-genai-image/src/test/java/org/springframework/ai/google/genai/image/GoogleGenAiImageRetryTests.java new file mode 100644 index 0000000000..d1c6080bc1 --- /dev/null +++ b/models/spring-ai-google-genai-image/src/test/java/org/springframework/ai/google/genai/image/GoogleGenAiImageRetryTests.java @@ -0,0 +1,171 @@ +/* + * Copyright 2023-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.google.genai.image; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.Optional; + +import com.google.genai.Client; +import com.google.genai.Models; +import com.google.genai.types.Blob; +import com.google.genai.types.Candidate; +import com.google.genai.types.Content; +import com.google.genai.types.GenerateContentConfig; +import com.google.genai.types.GenerateContentResponse; +import com.google.genai.types.Part; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import org.springframework.ai.image.ImagePrompt; +import org.springframework.ai.image.ImageResponse; +import org.springframework.ai.retry.RetryUtils; +import org.springframework.ai.retry.TransientAiException; +import org.springframework.core.retry.RetryListener; +import org.springframework.core.retry.RetryPolicy; +import org.springframework.core.retry.RetryTemplate; +import org.springframework.core.retry.Retryable; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +/** + * @author Olivier Le Quellec + */ +@ExtendWith(MockitoExtension.class) +public class GoogleGenAiImageRetryTests { + + private TestRetryListener retryListener; + + private RetryTemplate retryTemplate; + + private Client mockGenAiClient; + + @Mock + private Models mockModels; + + @Mock + private GoogleGenAiImageConnectionDetails mockConnectionDetails; + + private GoogleGenAiImageModel imageModel; + + @BeforeEach + public void setUp() throws Exception { + this.retryTemplate = RetryUtils.SHORT_RETRY_TEMPLATE; + this.retryListener = new TestRetryListener(); + this.retryTemplate.setRetryListener(this.retryListener); + + // Create a mock Client and use reflection to set the models field + this.mockGenAiClient = mock(Client.class); + Field modelsField = Client.class.getDeclaredField("models"); + modelsField.setAccessible(true); + modelsField.set(this.mockGenAiClient, this.mockModels); + + // Set up the mock connection details to return the mock client + given(this.mockConnectionDetails.getGenAiClient()).willReturn(this.mockGenAiClient); + given(this.mockConnectionDetails.getModelEndpointName(anyString())) + .willAnswer(invocation -> invocation.getArgument(0)); + + this.imageModel = new GoogleGenAiImageModel(this.mockConnectionDetails, + GoogleGenAiImageOptions.builder().build(), this.retryTemplate); + } + + @Test + public void googleGenAiImageTransientError() { + // Create mock image response: candidate -> content -> part -> inline image data + Blob mockBlob = mock(Blob.class); + given(mockBlob.data()).willReturn(Optional.of(new byte[] { 1, 2, 3 })); + given(mockBlob.mimeType()).willReturn(Optional.of("image/png")); + + Part mockPart = mock(Part.class); + given(mockPart.inlineData()).willReturn(Optional.of(mockBlob)); + + Content mockContent = mock(Content.class); + given(mockContent.parts()).willReturn(Optional.of(List.of(mockPart))); + + Candidate mockCandidate = mock(Candidate.class); + given(mockCandidate.content()).willReturn(Optional.of(mockContent)); + + GenerateContentResponse mockResponse = mock(GenerateContentResponse.class); + given(mockResponse.candidates()).willReturn(Optional.of(List.of(mockCandidate))); + + // Setup the mock client to throw transient errors then succeed + given(this.mockModels.generateContent(anyString(), anyString(), any(GenerateContentConfig.class))) + .willThrow(new TransientAiException("Transient Error 1")) + .willThrow(new TransientAiException("Transient Error 2")) + .willReturn(mockResponse); + + var options = GoogleGenAiImageOptions.builder().model("model").build(); + ImageResponse result = this.imageModel + .call(new ImagePrompt("A light cream colored mini golden doodle", options)); + + assertThat(result).isNotNull(); + assertThat(result.getResults()).hasSize(1); + assertThat(result.getResults().get(0).getOutput()).isNotNull(); + assertThat(this.retryListener.onSuccessRetryCount).isEqualTo(1); + assertThat(this.retryListener.onErrorRetryCount).isEqualTo(2); + + verify(this.mockModels, times(3)).generateContent(anyString(), anyString(), any(GenerateContentConfig.class)); + } + + @Test + public void googleGenAiImageNonTransientError() { + // Setup the mock client to throw a non-transient error + given(this.mockModels.generateContent(anyString(), anyString(), any(GenerateContentConfig.class))) + .willThrow(new RuntimeException("Non Transient Error")); + + var options = GoogleGenAiImageOptions.builder().model("model").build(); + // Assert that a RuntimeException is thrown and not retried + assertThatThrownBy( + () -> this.imageModel.call(new ImagePrompt("A light cream colored mini golden doodle", options))) + .isInstanceOf(RuntimeException.class); + + // Verify that generateContent was called only once (no retries for non-transient + // errors) + verify(this.mockModels, times(1)).generateContent(anyString(), anyString(), any(GenerateContentConfig.class)); + } + + private static class TestRetryListener implements RetryListener { + + int onErrorRetryCount = 0; + + int onSuccessRetryCount = 0; + + @Override + public void beforeRetry(final RetryPolicy retryPolicy, final Retryable retryable) { + // Count each retry attempt + this.onErrorRetryCount++; + } + + @Override + public void onRetrySuccess(final RetryPolicy retryPolicy, final Retryable retryable, final Object result) { + // Count successful retries - we increment when we succeed after a failure + this.onSuccessRetryCount++; + } + + } + +} diff --git a/pom.xml b/pom.xml index 649689350e..43699505d4 100644 --- a/pom.xml +++ b/pom.xml @@ -127,6 +127,7 @@ models/spring-ai-elevenlabs models/spring-ai-google-genai models/spring-ai-google-genai-embedding + models/spring-ai-google-genai-image models/spring-ai-mistral-ai models/spring-ai-ollama models/spring-ai-openai @@ -154,6 +155,7 @@ starters/spring-ai-starter-model-elevenlabs starters/spring-ai-starter-model-google-genai starters/spring-ai-starter-model-google-genai-embedding + starters/spring-ai-starter-model-google-genai-image starters/spring-ai-starter-model-mistral-ai starters/spring-ai-starter-model-ollama starters/spring-ai-starter-model-openai @@ -264,7 +266,7 @@ 2.41.22 24.09 26.83.0 - 1.54.0 + 1.56.0 0.22.0 3.9.1 0.32.0 diff --git a/spring-ai-bom/pom.xml b/spring-ai-bom/pom.xml index dcdd13e130..4f0c5672b5 100644 --- a/spring-ai-bom/pom.xml +++ b/spring-ai-bom/pom.xml @@ -286,6 +286,12 @@ ${project.version} + + org.springframework.ai + spring-ai-google-genai-image + ${project.version} + + org.springframework.ai spring-ai-mistral-ai @@ -1009,6 +1015,12 @@ ${project.version} + + org.springframework.ai + spring-ai-starter-model-google-genai-image + ${project.version} + + org.springframework.ai spring-ai-starter-model-deepseek diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/image/google-genai-image.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/image/google-genai-image.adoc new file mode 100644 index 0000000000..de4c023e37 --- /dev/null +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/image/google-genai-image.adoc @@ -0,0 +1,279 @@ += Google GenAI Image + +The https://ai.google.dev/gemini-api/docs/models/gemini-2.5-flash-image[Nano Banana] and https://ai.google.dev/gemini-api/docs/models/gemini-3.1-flash-image[Nano Banana 2] provides image generation using Google's image models through either the Gemini Developer API or Vertex AI. + +They provide high-quality image generation and conversational editing at a mainstream price point and low latency. + +It serves as the high-efficiency counterpart to https://ai.google.dev/gemini-api/docs/models/gemini-3-pro-image[Gemini 3 Pro Image], optimized for speed and high-volume developer use cases. + + +This implementation provides two authentication modes: + +- **Gemini Developer API**: Use an API key for quick prototyping and development +- **Vertex AI**: Use Google Cloud credentials for production deployments with enterprise features + +== Prerequisites + +Choose one of the following authentication methods: + +=== Option 1: Gemini Developer API (API Key) + +- Obtain an API key from the https://aistudio.google.com/app/apikey[Google AI Studio] +- Set the API key as an environment variable or in your application properties + +=== Option 2: Vertex AI (Google Cloud) + +- Install the link:https://cloud.google.com/sdk/docs/install[gcloud] CLI, appropriate for your OS. +- Authenticate by running the following command. +Replace `PROJECT_ID` with your Google Cloud project ID and `ACCOUNT` with your Google Cloud username. + +[source] +---- +gcloud config set project && +gcloud auth application-default login +---- + +=== Add Repositories and BOM + +Spring AI artifacts are published in Maven Central and Spring Snapshot repositories. +Refer to the xref:getting-started.adoc#artifact-repositories[Artifact Repositories] section to add these repositories to your build system. + +To help with dependency management, Spring AI provides a BOM (bill of materials) to ensure that a consistent version of Spring AI is used throughout the entire project. Refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section to add the Spring AI BOM to your build system. + + +== Auto-configuration + +[NOTE] +==== +There has been a significant change in the Spring AI auto-configuration, starter modules' artifact names. +Please refer to the https://docs.spring.io/spring-ai/reference/upgrade-notes.html[upgrade notes] for more information. +==== + +Spring AI provides Spring Boot auto-configuration for the Google GenAI Imagen Model. +To enable it add the following dependency to your project's Maven `pom.xml` file: + +[source, xml] +---- + + org.springframework.ai + spring-ai-starter-model-google-genai-image + +---- + +or to your Gradle `build.gradle` build file. + +[source,groovy] +---- +dependencies { + implementation 'org.springframework.ai:spring-ai-starter-model-google-genai-image' +} +---- + +TIP: Refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section to add the Spring AI BOM to your build file. + +=== Imagen Properties + +==== Connection Properties + +The prefix `spring.ai.google.genai.image` is used as the property prefix that lets you connect to Google GenAI Imagen API. + +[NOTE] +==== +The connection properties are shared with the Google GenAI Chat module. If you're using both chat and image, you only need to configure the connection once using either `spring.ai.google.genai` prefix (for chat) or `spring.ai.google.genai.image` prefix (for images). +==== + +[cols="3,5,1", stripes=even] +|==== +| Property | Description | Default + +| spring.ai.google.genai.image.api-key | API key for Gemini Developer API. When provided, the client uses the Gemini Developer API instead of Vertex AI. | - +| spring.ai.google.genai.image.project-id | Google Cloud Platform project ID (required for Vertex AI mode) | - +| spring.ai.google.genai.image.location | Google Cloud region (required for Vertex AI mode) | - +| spring.ai.google.genai.image.credentials-uri | URI to Google Cloud credentials. When provided it is used to create a `GoogleCredentials` instance for authentication. | - +|==== + +[NOTE] +==== +Enabling and disabling of the Imagen auto-configurations are now configured via top level properties with the prefix `spring.ai.model.image`. + +To enable, spring.ai.model.image=google-genai (It is enabled by default) + +To disable, spring.ai.model.image=none (or any value which doesn't match google-genai) + +This change is done to allow configuration of multiple models. +==== + +==== Imagen Properties + +The prefix `spring.ai.google.genai.image` is the property prefix that lets you configure the image model implementation for Google GenAI Imagen. + +[cols="3,5,1", stripes=even] +|==== +| Property | Description | Default + +| spring.ai.model.image | Enable Google GenAI Imagen API model. | google-genai + +| spring.ai.google.genai.image.options.n | The number of images to generate. Must be between 1 and 4. | 4 +| spring.ai.google.genai.image.options.model | The https://docs.cloud.google.com/gemini-enterprise-agent-platform/models[Google GenAI model] to use. Supported models include `gemini-2.5-flash-image`, `gemini-3-pro-image` and `gemini-3.1-flash-image` | - +| spring.ai.google.genai.image.options.negative-prompt | A description of what to discourage in the generated images | - +| spring.ai.google.genai.image.options.aspect-ratio | The aspect ratio of the generated images. Supported values are 1:1, 3:4, 4:3, 9:16, and 16:9 | 1:1 +| spring.ai.google.genai.image.options.guidance-scale | Controls how much the model adheres to the text prompt. Large values increase output and prompt alignment, but may compromise image quality. | - +| spring.ai.google.genai.image.options.seed | Random seed for image generation. This is not available when `add-watermark` is set to true. | - +| spring.ai.google.genai.image.options.safety-filter-level | Filter level for safety filtering. Supported values are BLOCK_LOW_AND_ABOVE, BLOCK_MEDIUM_AND_ABOVE, BLOCK_ONLY_HIGH and BLOCK_NONE. | - +| spring.ai.google.genai.image.options.person-generation | Allows generation of people by the model. Supported values are DONT_ALLOW, ALLOW_ADULT and ALLOW_ALL. | - +| spring.ai.google.genai.image.options.include-safety-attributes | Whether to report the safety scores of each generated image and the positive prompt in the response. | - +| spring.ai.google.genai.image.options.include-rai-reason | Whether to include the Responsible AI filter reason if the image is filtered out of the response. | - +| spring.ai.google.genai.image.options.language | Language of the text in the prompt. | - +| spring.ai.google.genai.image.options.output-mime-type | MIME type of the generated image. | - +| spring.ai.google.genai.image.options.output-compression-quality | Compression quality of the generated image (for `image/jpeg` only). | - +| spring.ai.google.genai.image.options.labels | User specified labels to track billing usage. | - +| spring.ai.google.genai.image.options.image-size | The size of the largest dimension of the generated image. Supported sizes are 1K and 2K (not supported for Imagen 3 models). | - +| spring.ai.google.genai.image.options.enhance-prompt | Whether to use the prompt rewriting logic. | - +| spring.ai.google.genai.image.options.temperature | Controls the degree of randomness in token selection. Lower values produce less open-ended responses, higher values produce more diverse or creative results. | - +| spring.ai.google.genai.image.options.top-p | Tokens are selected from the most to least probable until the sum of their probabilities equals this value. | - +| spring.ai.google.genai.image.options.top-k | For each token selection step, the `top-k` tokens with the highest probabilities are sampled. | - +| spring.ai.google.genai.image.options.max-output-tokens | Maximum number of tokens that can be generated in the response. | - +| spring.ai.google.genai.image.options.stop-sequences | List of strings that tells the model to stop generating text if one of the strings is encountered in the response. | - +| spring.ai.google.genai.image.options.response-logprobs | Whether to return the log probabilities of the tokens that were chosen by the model at each step. | - +| spring.ai.google.genai.image.options.logprobs | Number of top candidate tokens to return the log probabilities for at each generation step. | - +| spring.ai.google.genai.image.options.presence-penalty | Positive values penalize tokens that already appear in the generated text, increasing the probability of generating more diverse content. | - +| spring.ai.google.genai.image.options.frequency-penalty | Positive values penalize tokens that repeatedly appear in the generated text, increasing the probability of generating more diverse content. | - +| spring.ai.google.genai.image.options.response-mime-type | Output response mime type of the generated candidate text (e.g. `text/plain`, `application/json`). | - +| spring.ai.google.genai.image.options.media-resolution | The media resolution to use, if specified. | - +| spring.ai.google.genai.image.options.cached-content | Resource name of a context cache that can be used in subsequent requests. | - +| spring.ai.google.genai.image.options.service-tier | The service tier to use for the request. | - + +|==== + +== Sample Controller + +https://start.spring.io/[Create] a new Spring Boot project and add the `spring-ai-starter-model-google-genai-image` to your pom (or gradle) dependencies. + +Add a `application.properties` file, under the `src/main/resources` directory, to enable and configure the Google GenAI image model: + +=== Using Gemini Developer API (API Key) + +[source,application.properties] +---- +spring.ai.google.genai.image.api-key=YOUR_API_KEY +spring.ai.google.genai.image.model=imagen-4.0-generate-001 +---- + +=== Using Vertex AI + +[source,application.properties] +---- +spring.ai.google.genai.image.project-id=YOUR_PROJECT_ID +spring.ai.google.genai.image.location=YOUR_PROJECT_LOCATION +spring.ai.google.genai.image.model=imagen-4.0-generate-001 +---- + + +This will create a `GoogleGenAiImageModel` implementation that you can inject into your class. +Here is an example of a simple `@Controller` class that uses the image model for images generations. + +[source,java] +---- +@RestController +public class ImageController { + + private final ImageModel imageModel; + + @Autowired + public ImageController(ImageModel imageModel) { + this.imageModel = imageModel; + } + + @GetMapping("/ai/image") + public Map generate(@RequestParam(value = "message", defaultValue = "A painting of a sunset over a mountain") String message) { + ImagePrompt imagePrompt = new ImagePrompt(message); + ImageResponse imageResponse = this.imageModel.call(imagePrompt); + return Map.of("images", imageResponse); + } +} +---- + +== Manual Configuration + +The https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-google-genai-image/src/main/java/org/springframework/ai/google/genai/GoogleGenAiImageModel.java[GoogleGenAiImageModel] implements the `ImageModel`. + +Add the `spring-ai-google-genai-image` dependency to your project's Maven `pom.xml` file: + +[source, xml] +---- + + org.springframework.ai + spring-ai-google-genai-image + +---- + +or to your Gradle `build.gradle` build file. + +[source,groovy] +---- +dependencies { + implementation 'org.springframework.ai:spring-ai-google-genai-image' +} +---- + +TIP: Refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section to add the Spring AI BOM to your build file. + +Next, create a `GoogleGenAiImageModel` and use it for images: + +=== Using API Key + +[source,java] +---- +GoogleGenAiImageConnectionDetails connectionDetails = + GoogleGenAiImageConnectionDetails.builder() + .apiKey(System.getenv("GOOGLE_API_KEY")) + .build(); + +GoogleGenAiImageOptions options = GoogleGenAiImageOptions.builder() + .model(GoogleGenAiImageOptions.DEFAULT_MODEL_NAME) + .build(); + +var imageModel = new GoogleGenAiImageModel(connectionDetails, options); + +ImageResponse imageResponse = imageModel + .call("A painting of a sunset over a mountain"); +---- + +=== Using Vertex AI + +[source,java] +---- +GoogleGenAiImageConnectionDetails connectionDetails = + GoogleGenAiImageConnectionDetails.builder() + .projectId(System.getenv("GOOGLE_CLOUD_PROJECT")) + .location(System.getenv("GOOGLE_CLOUD_LOCATION")) + .build(); + +GoogleGenAiImageOptions options = GoogleGenAiImageOptions.builder() + .model(GoogleGenAiImageOptions.DEFAULT_MODEL_NAME) + .build(); + +var imageModel = new GoogleGenAiImageModel(connectionDetails, options); + +ImageResponse imageResponse = imageModel + .call("A painting of a sunset over a mountain"); +---- + +== Safety Filter Levels + +The Google GenAI image API supports different filter levels for safety filtering: + +- `BLOCK_LOW_AND_ABOVE`: Block when low, medium or high probability of unsafe content +- `BLOCK_MEDIUM_AND_ABOVE`: Block when medium or high probability of unsafe content +- `BLOCK_ONLY_HIGH`: Block when high probability of unsafe content +- `BLOCK_NONE`: Always show regardless of probability of unsafe content +- `SAFETY_FILTER_LEVEL_UNSPECIFIED`: Threshold is unspecified, block using default threshold + +== Person Generation + +The Google GenAI image API allows the model to generate images of people with different levels: + +- `DONT_ALLOW`: Block generation of images of people +- `ALLOW_ADULT`: Generate images of adults, but not children +- `ALLOW_ALL`: Generate images that include adults and children +- `PERSON_GENERATION_UNSPECIFIED`: Threshold is unspecified, generate images of people using default threshold diff --git a/starters/spring-ai-starter-model-google-genai-image/pom.xml b/starters/spring-ai-starter-model-google-genai-image/pom.xml new file mode 100644 index 0000000000..4eeb442f61 --- /dev/null +++ b/starters/spring-ai-starter-model-google-genai-image/pom.xml @@ -0,0 +1,52 @@ + + + + + 4.0.0 + + org.springframework.ai + spring-ai-parent + 2.0.0-SNAPSHOT + ../../pom.xml + + spring-ai-starter-model-google-genai-image + jar + Spring AI Starter - Google Genai Image + Spring AI Google Genai Image Spring Boot Starter + https://github.com/spring-projects/spring-ai + + + + + org.springframework.boot + spring-boot-starter + + + + org.springframework.ai + spring-ai-autoconfigure-model-google-genai + ${project.parent.version} + + + + org.springframework.ai + spring-ai-google-genai-image + ${project.parent.version} + + + + From 9334368f5deb0d29c5a759212fdd1a0bdfcb6f46 Mon Sep 17 00:00:00 2001 From: Olivier LE-QUELLEC Date: Fri, 5 Jun 2026 19:16:45 +0200 Subject: [PATCH 2/4] fix: remove imagen from documentation and tests Signed-off-by: Olivier LE-QUELLEC --- .../autoconfigure/image/package-info.java | 2 +- .../GoogleGenAiImagePropertiesTests.java | 4 ++-- models/spring-ai-google-genai-image/pom.xml | 2 +- .../image/GoogleGenAiImageModelName.java | 2 +- .../ai/google/genai/image/package-info.java | 2 +- .../pages/api/image/google-genai-image.adoc | 20 +++++++++---------- 6 files changed, 16 insertions(+), 16 deletions(-) diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-google-genai/src/main/java/org/springframework/ai/model/google/genai/autoconfigure/image/package-info.java b/auto-configurations/models/spring-ai-autoconfigure-model-google-genai/src/main/java/org/springframework/ai/model/google/genai/autoconfigure/image/package-info.java index f38db5ec0f..16bd2962e3 100644 --- a/auto-configurations/models/spring-ai-autoconfigure-model-google-genai/src/main/java/org/springframework/ai/model/google/genai/autoconfigure/image/package-info.java +++ b/auto-configurations/models/spring-ai-autoconfigure-model-google-genai/src/main/java/org/springframework/ai/model/google/genai/autoconfigure/image/package-info.java @@ -15,7 +15,7 @@ */ /** - * Auto-configuration for Google GenAI Imagen image generation. + * Auto-configuration for Google GenAI image generation. */ @org.jspecify.annotations.NullMarked package org.springframework.ai.model.google.genai.autoconfigure.image; diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-google-genai/src/test/java/org/springframework/ai/model/google/genai/autoconfigure/image/GoogleGenAiImagePropertiesTests.java b/auto-configurations/models/spring-ai-autoconfigure-model-google-genai/src/test/java/org/springframework/ai/model/google/genai/autoconfigure/image/GoogleGenAiImagePropertiesTests.java index 2650d570f6..88b4fd5639 100644 --- a/auto-configurations/models/spring-ai-autoconfigure-model-google-genai/src/test/java/org/springframework/ai/model/google/genai/autoconfigure/image/GoogleGenAiImagePropertiesTests.java +++ b/auto-configurations/models/spring-ai-autoconfigure-model-google-genai/src/test/java/org/springframework/ai/model/google/genai/autoconfigure/image/GoogleGenAiImagePropertiesTests.java @@ -53,7 +53,7 @@ void connectionPropertiesBinding() { @Test void optionsPropertiesBinding() { this.contextRunner - .withPropertyValues("spring.ai.google.genai.image.model=imagen-4.0-ultra-generate-001", + .withPropertyValues("spring.ai.google.genai.image.model=gemini-2.5-flash-image", "spring.ai.google.genai.image.n=2", "spring.ai.google.genai.image.aspect-ratio=16:9", "spring.ai.google.genai.image.negative-prompt=blurry", "spring.ai.google.genai.image.guidance-scale=5.0", "spring.ai.google.genai.image.seed=42", @@ -78,7 +78,7 @@ void optionsPropertiesBinding() { .run(context -> { GoogleGenAiImageProperties props = context.getBean(GoogleGenAiImageProperties.class); GoogleGenAiImageOptions options = props.toOptions(); - assertThat(options.getModel()).isEqualTo("imagen-4.0-ultra-generate-001"); + assertThat(options.getModel()).isEqualTo("gemini-2.5-flash-image"); assertThat(options.getN()).isEqualTo(2); assertThat(options.getAspectRatio()).isEqualTo("16:9"); assertThat(options.getNegativePrompt()).isEqualTo("blurry"); diff --git a/models/spring-ai-google-genai-image/pom.xml b/models/spring-ai-google-genai-image/pom.xml index e3acfaa411..e65d42a416 100644 --- a/models/spring-ai-google-genai-image/pom.xml +++ b/models/spring-ai-google-genai-image/pom.xml @@ -27,7 +27,7 @@ spring-ai-google-genai-image jar Spring AI Model - Google GenAI Image - Google GenAI Imagen image generation models support + Google GenAI image generation models support https://github.com/spring-projects/spring-ai diff --git a/models/spring-ai-google-genai-image/src/main/java/org/springframework/ai/google/genai/image/GoogleGenAiImageModelName.java b/models/spring-ai-google-genai-image/src/main/java/org/springframework/ai/google/genai/image/GoogleGenAiImageModelName.java index 0ec15920ab..dfad252507 100644 --- a/models/spring-ai-google-genai-image/src/main/java/org/springframework/ai/google/genai/image/GoogleGenAiImageModelName.java +++ b/models/spring-ai-google-genai-image/src/main/java/org/springframework/ai/google/genai/image/GoogleGenAiImageModelName.java @@ -19,7 +19,7 @@ import org.springframework.ai.model.ModelDescription; /** - * Known Google GenAI Imagen image generation model names. + * Known Google GenAI image generation model names. * * @author Olivier Le Quellec * @since 1.1.0 diff --git a/models/spring-ai-google-genai-image/src/main/java/org/springframework/ai/google/genai/image/package-info.java b/models/spring-ai-google-genai-image/src/main/java/org/springframework/ai/google/genai/image/package-info.java index 3bf5339d8a..eacb919379 100644 --- a/models/spring-ai-google-genai-image/src/main/java/org/springframework/ai/google/genai/image/package-info.java +++ b/models/spring-ai-google-genai-image/src/main/java/org/springframework/ai/google/genai/image/package-info.java @@ -15,7 +15,7 @@ */ /** - * Google GenAI Imagen image generation support for Spring AI. + * Google GenAI image generation support for Spring AI. */ @org.jspecify.annotations.NullMarked package org.springframework.ai.google.genai.image; diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/image/google-genai-image.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/image/google-genai-image.adoc index de4c023e37..1d60df331d 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/image/google-genai-image.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/image/google-genai-image.adoc @@ -49,7 +49,7 @@ There has been a significant change in the Spring AI auto-configuration, starter Please refer to the https://docs.spring.io/spring-ai/reference/upgrade-notes.html[upgrade notes] for more information. ==== -Spring AI provides Spring Boot auto-configuration for the Google GenAI Imagen Model. +Spring AI provides Spring Boot auto-configuration for the Google GenAI image Model. To enable it add the following dependency to your project's Maven `pom.xml` file: [source, xml] @@ -71,11 +71,11 @@ dependencies { TIP: Refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section to add the Spring AI BOM to your build file. -=== Imagen Properties +=== Image generation Properties ==== Connection Properties -The prefix `spring.ai.google.genai.image` is used as the property prefix that lets you connect to Google GenAI Imagen API. +The prefix `spring.ai.google.genai.image` is used as the property prefix that lets you connect to Google GenAI image generation API. [NOTE] ==== @@ -94,7 +94,7 @@ The connection properties are shared with the Google GenAI Chat module. If you'r [NOTE] ==== -Enabling and disabling of the Imagen auto-configurations are now configured via top level properties with the prefix `spring.ai.model.image`. +Enabling and disabling of the auto-configurations are now configured via top level properties with the prefix `spring.ai.model.image`. To enable, spring.ai.model.image=google-genai (It is enabled by default) @@ -103,15 +103,15 @@ To disable, spring.ai.model.image=none (or any value which doesn't match google- This change is done to allow configuration of multiple models. ==== -==== Imagen Properties +==== Image generation Properties -The prefix `spring.ai.google.genai.image` is the property prefix that lets you configure the image model implementation for Google GenAI Imagen. +The prefix `spring.ai.google.genai.image` is the property prefix that lets you configure the image model implementation for Google GenAI. [cols="3,5,1", stripes=even] |==== | Property | Description | Default -| spring.ai.model.image | Enable Google GenAI Imagen API model. | google-genai +| spring.ai.model.image | Enable Google GenAI API model. | google-genai | spring.ai.google.genai.image.options.n | The number of images to generate. Must be between 1 and 4. | 4 | spring.ai.google.genai.image.options.model | The https://docs.cloud.google.com/gemini-enterprise-agent-platform/models[Google GenAI model] to use. Supported models include `gemini-2.5-flash-image`, `gemini-3-pro-image` and `gemini-3.1-flash-image` | - @@ -127,7 +127,7 @@ The prefix `spring.ai.google.genai.image` is the property prefix that lets you c | spring.ai.google.genai.image.options.output-mime-type | MIME type of the generated image. | - | spring.ai.google.genai.image.options.output-compression-quality | Compression quality of the generated image (for `image/jpeg` only). | - | spring.ai.google.genai.image.options.labels | User specified labels to track billing usage. | - -| spring.ai.google.genai.image.options.image-size | The size of the largest dimension of the generated image. Supported sizes are 1K and 2K (not supported for Imagen 3 models). | - +| spring.ai.google.genai.image.options.image-size | The size of the largest dimension of the generated image. Supported sizes are 1K and 2K. | - | spring.ai.google.genai.image.options.enhance-prompt | Whether to use the prompt rewriting logic. | - | spring.ai.google.genai.image.options.temperature | Controls the degree of randomness in token selection. Lower values produce less open-ended responses, higher values produce more diverse or creative results. | - | spring.ai.google.genai.image.options.top-p | Tokens are selected from the most to least probable until the sum of their probabilities equals this value. | - @@ -156,7 +156,7 @@ Add a `application.properties` file, under the `src/main/resources` directory, t [source,application.properties] ---- spring.ai.google.genai.image.api-key=YOUR_API_KEY -spring.ai.google.genai.image.model=imagen-4.0-generate-001 +spring.ai.google.genai.image.model=gemini-2.5-flash-image ---- === Using Vertex AI @@ -165,7 +165,7 @@ spring.ai.google.genai.image.model=imagen-4.0-generate-001 ---- spring.ai.google.genai.image.project-id=YOUR_PROJECT_ID spring.ai.google.genai.image.location=YOUR_PROJECT_LOCATION -spring.ai.google.genai.image.model=imagen-4.0-generate-001 +spring.ai.google.genai.image.model=gemini-2.5-flash-image ---- From e44d12e109762f371ba115ec8051382a0f4cac9e Mon Sep 17 00:00:00 2001 From: Olivier LE-QUELLEC Date: Sat, 6 Jun 2026 17:36:49 +0200 Subject: [PATCH 3/4] remove obsolete params Signed-off-by: Olivier LE-QUELLEC --- .../image/GoogleGenAiImageProperties.java | 166 --------- .../GoogleGenAiImagePropertiesTests.java | 35 +- .../genai/image/GoogleGenAiImageModel.java | 27 -- .../genai/image/GoogleGenAiImageOptions.java | 340 +----------------- .../pages/api/image/google-genai-image.adoc | 15 - 5 files changed, 10 insertions(+), 573 deletions(-) diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-google-genai/src/main/java/org/springframework/ai/model/google/genai/autoconfigure/image/GoogleGenAiImageProperties.java b/auto-configurations/models/spring-ai-autoconfigure-model-google-genai/src/main/java/org/springframework/ai/model/google/genai/autoconfigure/image/GoogleGenAiImageProperties.java index e732ff70e0..ffb5b3ea94 100644 --- a/auto-configurations/models/spring-ai-autoconfigure-model-google-genai/src/main/java/org/springframework/ai/model/google/genai/autoconfigure/image/GoogleGenAiImageProperties.java +++ b/auto-configurations/models/spring-ai-autoconfigure-model-google-genai/src/main/java/org/springframework/ai/model/google/genai/autoconfigure/image/GoogleGenAiImageProperties.java @@ -16,7 +16,6 @@ package org.springframework.ai.model.google.genai.autoconfigure.image; -import java.util.List; import java.util.Map; import org.jspecify.annotations.Nullable; @@ -39,24 +38,14 @@ public class GoogleGenAiImageProperties { private @Nullable Integer n; - private @Nullable String negativePrompt; - private @Nullable String aspectRatio = GoogleGenAiImageOptions.DEFAULT_ASPECT_RATIO; - private @Nullable Float guidanceScale; - private @Nullable Integer seed; private GoogleGenAiImageOptions.@Nullable SafetyFilterLevel safetyFilterLevel; private GoogleGenAiImageOptions.@Nullable PersonGeneration personGeneration; - private @Nullable Boolean includeSafetyAttributes; - - private @Nullable Boolean includeRaiReason; - - private @Nullable String language; - private @Nullable String outputMimeType; private @Nullable Integer outputCompressionQuality; @@ -65,8 +54,6 @@ public class GoogleGenAiImageProperties { private @Nullable String imageSize; - private @Nullable Boolean enhancePrompt; - private @Nullable Float temperature; private @Nullable Float topP; @@ -75,24 +62,6 @@ public class GoogleGenAiImageProperties { private @Nullable Integer maxOutputTokens; - private @Nullable List stopSequences; - - private @Nullable Boolean responseLogprobs; - - private @Nullable Integer logprobs; - - private @Nullable Float presencePenalty; - - private @Nullable Float frequencyPenalty; - - private @Nullable String responseMimeType; - - private @Nullable String mediaResolution; - - private @Nullable String cachedContent; - - private @Nullable String serviceTier; - public @Nullable String getModel() { return this.model; } @@ -109,14 +78,6 @@ public void setN(@Nullable Integer n) { this.n = n; } - public @Nullable String getNegativePrompt() { - return this.negativePrompt; - } - - public void setNegativePrompt(@Nullable String negativePrompt) { - this.negativePrompt = negativePrompt; - } - public @Nullable String getAspectRatio() { return this.aspectRatio; } @@ -125,14 +86,6 @@ public void setAspectRatio(@Nullable String aspectRatio) { this.aspectRatio = aspectRatio; } - public @Nullable Float getGuidanceScale() { - return this.guidanceScale; - } - - public void setGuidanceScale(@Nullable Float guidanceScale) { - this.guidanceScale = guidanceScale; - } - public @Nullable Integer getSeed() { return this.seed; } @@ -157,30 +110,6 @@ public void setPersonGeneration(GoogleGenAiImageOptions.@Nullable PersonGenerati this.personGeneration = personGeneration; } - public @Nullable Boolean getIncludeSafetyAttributes() { - return this.includeSafetyAttributes; - } - - public void setIncludeSafetyAttributes(@Nullable Boolean includeSafetyAttributes) { - this.includeSafetyAttributes = includeSafetyAttributes; - } - - public @Nullable Boolean getIncludeRaiReason() { - return this.includeRaiReason; - } - - public void setIncludeRaiReason(@Nullable Boolean includeRaiReason) { - this.includeRaiReason = includeRaiReason; - } - - public @Nullable String getLanguage() { - return this.language; - } - - public void setLanguage(@Nullable String language) { - this.language = language; - } - public @Nullable String getOutputMimeType() { return this.outputMimeType; } @@ -213,14 +142,6 @@ public void setImageSize(@Nullable String imageSize) { this.imageSize = imageSize; } - public @Nullable Boolean getEnhancePrompt() { - return this.enhancePrompt; - } - - public void setEnhancePrompt(@Nullable Boolean enhancePrompt) { - this.enhancePrompt = enhancePrompt; - } - public @Nullable Float getTemperature() { return this.temperature; } @@ -253,109 +174,22 @@ public void setMaxOutputTokens(@Nullable Integer maxOutputTokens) { this.maxOutputTokens = maxOutputTokens; } - public @Nullable List getStopSequences() { - return this.stopSequences; - } - - public void setStopSequences(@Nullable List stopSequences) { - this.stopSequences = stopSequences; - } - - public @Nullable Boolean getResponseLogprobs() { - return this.responseLogprobs; - } - - public void setResponseLogprobs(@Nullable Boolean responseLogprobs) { - this.responseLogprobs = responseLogprobs; - } - - public @Nullable Integer getLogprobs() { - return this.logprobs; - } - - public void setLogprobs(@Nullable Integer logprobs) { - this.logprobs = logprobs; - } - - public @Nullable Float getPresencePenalty() { - return this.presencePenalty; - } - - public void setPresencePenalty(@Nullable Float presencePenalty) { - this.presencePenalty = presencePenalty; - } - - public @Nullable Float getFrequencyPenalty() { - return this.frequencyPenalty; - } - - public void setFrequencyPenalty(@Nullable Float frequencyPenalty) { - this.frequencyPenalty = frequencyPenalty; - } - - public @Nullable String getResponseMimeType() { - return this.responseMimeType; - } - - public void setResponseMimeType(@Nullable String responseMimeType) { - this.responseMimeType = responseMimeType; - } - - public @Nullable String getMediaResolution() { - return this.mediaResolution; - } - - public void setMediaResolution(@Nullable String mediaResolution) { - this.mediaResolution = mediaResolution; - } - - public @Nullable String getCachedContent() { - return this.cachedContent; - } - - public void setCachedContent(@Nullable String cachedContent) { - this.cachedContent = cachedContent; - } - - public @Nullable String getServiceTier() { - return this.serviceTier; - } - - public void setServiceTier(@Nullable String serviceTier) { - this.serviceTier = serviceTier; - } - public GoogleGenAiImageOptions toOptions() { return GoogleGenAiImageOptions.builder() .model(this.model) .n(this.n) - .negativePrompt(this.negativePrompt) .aspectRatio(this.aspectRatio) - .guidanceScale(this.guidanceScale) .seed(this.seed) .safetyFilterLevel(this.safetyFilterLevel) .personGeneration(this.personGeneration) - .includeSafetyAttributes(this.includeSafetyAttributes) - .includeRaiReason(this.includeRaiReason) - .language(this.language) .outputMimeType(this.outputMimeType) .outputCompressionQuality(this.outputCompressionQuality) .labels(this.labels) .imageSize(this.imageSize) - .enhancePrompt(this.enhancePrompt) .temperature(this.temperature) .topP(this.topP) .topK(this.topK) .maxOutputTokens(this.maxOutputTokens) - .stopSequences(this.stopSequences) - .responseLogprobs(this.responseLogprobs) - .logprobs(this.logprobs) - .presencePenalty(this.presencePenalty) - .frequencyPenalty(this.frequencyPenalty) - .responseMimeType(this.responseMimeType) - .mediaResolution(this.mediaResolution) - .cachedContent(this.cachedContent) - .serviceTier(this.serviceTier) .build(); } diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-google-genai/src/test/java/org/springframework/ai/model/google/genai/autoconfigure/image/GoogleGenAiImagePropertiesTests.java b/auto-configurations/models/spring-ai-autoconfigure-model-google-genai/src/test/java/org/springframework/ai/model/google/genai/autoconfigure/image/GoogleGenAiImagePropertiesTests.java index 88b4fd5639..31a3ef60de 100644 --- a/auto-configurations/models/spring-ai-autoconfigure-model-google-genai/src/test/java/org/springframework/ai/model/google/genai/autoconfigure/image/GoogleGenAiImagePropertiesTests.java +++ b/auto-configurations/models/spring-ai-autoconfigure-model-google-genai/src/test/java/org/springframework/ai/model/google/genai/autoconfigure/image/GoogleGenAiImagePropertiesTests.java @@ -55,60 +55,33 @@ void optionsPropertiesBinding() { this.contextRunner .withPropertyValues("spring.ai.google.genai.image.model=gemini-2.5-flash-image", "spring.ai.google.genai.image.n=2", "spring.ai.google.genai.image.aspect-ratio=16:9", - "spring.ai.google.genai.image.negative-prompt=blurry", - "spring.ai.google.genai.image.guidance-scale=5.0", "spring.ai.google.genai.image.seed=42", + "spring.ai.google.genai.image.seed=42", "spring.ai.google.genai.image.safety-filter-level=BLOCK_ONLY_HIGH", "spring.ai.google.genai.image.person-generation=ALLOW_ADULT", - "spring.ai.google.genai.image.include-safety-attributes=true", - "spring.ai.google.genai.image.include-rai-reason=true", "spring.ai.google.genai.image.language=en", "spring.ai.google.genai.image.output-mime-type=image/png", "spring.ai.google.genai.image.output-compression-quality=80", - "spring.ai.google.genai.image.image-size=2K", "spring.ai.google.genai.image.enhance-prompt=true", - "spring.ai.google.genai.image.labels.env=test", "spring.ai.google.genai.image.temperature=0.7", - "spring.ai.google.genai.image.top-p=0.9", "spring.ai.google.genai.image.top-k=40", - "spring.ai.google.genai.image.max-output-tokens=1024", - "spring.ai.google.genai.image.stop-sequences=STOP,END", - "spring.ai.google.genai.image.response-logprobs=true", "spring.ai.google.genai.image.logprobs=5", - "spring.ai.google.genai.image.presence-penalty=0.5", - "spring.ai.google.genai.image.frequency-penalty=0.25", - "spring.ai.google.genai.image.response-mime-type=application/json", - "spring.ai.google.genai.image.media-resolution=MEDIA_RESOLUTION_HIGH", - "spring.ai.google.genai.image.cached-content=cachedContents/abc", - "spring.ai.google.genai.image.service-tier=FLEX") + "spring.ai.google.genai.image.image-size=2K", "spring.ai.google.genai.image.labels.env=test", + "spring.ai.google.genai.image.temperature=0.7", "spring.ai.google.genai.image.top-p=0.9", + "spring.ai.google.genai.image.top-k=40", "spring.ai.google.genai.image.max-output-tokens=1024") .run(context -> { GoogleGenAiImageProperties props = context.getBean(GoogleGenAiImageProperties.class); GoogleGenAiImageOptions options = props.toOptions(); assertThat(options.getModel()).isEqualTo("gemini-2.5-flash-image"); assertThat(options.getN()).isEqualTo(2); assertThat(options.getAspectRatio()).isEqualTo("16:9"); - assertThat(options.getNegativePrompt()).isEqualTo("blurry"); - assertThat(options.getGuidanceScale()).isEqualTo(5.0f); assertThat(options.getSeed()).isEqualTo(42); assertThat(options.getSafetyFilterLevel()) .isEqualTo(GoogleGenAiImageOptions.SafetyFilterLevel.BLOCK_ONLY_HIGH); assertThat(options.getPersonGeneration()) .isEqualTo(GoogleGenAiImageOptions.PersonGeneration.ALLOW_ADULT); - assertThat(options.getIncludeSafetyAttributes()).isTrue(); - assertThat(options.getIncludeRaiReason()).isTrue(); - assertThat(options.getLanguage()).isEqualTo("en"); assertThat(options.getOutputMimeType()).isEqualTo("image/png"); assertThat(options.getOutputCompressionQuality()).isEqualTo(80); assertThat(options.getImageSize()).isEqualTo("2K"); - assertThat(options.getEnhancePrompt()).isTrue(); assertThat(options.getLabels()).containsEntry("env", "test"); assertThat(options.getTemperature()).isEqualTo(0.7f); assertThat(options.getTopP()).isEqualTo(0.9f); assertThat(options.getTopK()).isEqualTo(40.0f); assertThat(options.getMaxOutputTokens()).isEqualTo(1024); - assertThat(options.getStopSequences()).containsExactly("STOP", "END"); - assertThat(options.getResponseLogprobs()).isTrue(); - assertThat(options.getLogprobs()).isEqualTo(5); - assertThat(options.getPresencePenalty()).isEqualTo(0.5f); - assertThat(options.getFrequencyPenalty()).isEqualTo(0.25f); - assertThat(options.getResponseMimeType()).isEqualTo("application/json"); - assertThat(options.getMediaResolution()).isEqualTo("MEDIA_RESOLUTION_HIGH"); - assertThat(options.getCachedContent()).isEqualTo("cachedContents/abc"); - assertThat(options.getServiceTier()).isEqualTo("FLEX"); }); } diff --git a/models/spring-ai-google-genai-image/src/main/java/org/springframework/ai/google/genai/image/GoogleGenAiImageModel.java b/models/spring-ai-google-genai-image/src/main/java/org/springframework/ai/google/genai/image/GoogleGenAiImageModel.java index 7db2ea64df..484958d8f0 100644 --- a/models/spring-ai-google-genai-image/src/main/java/org/springframework/ai/google/genai/image/GoogleGenAiImageModel.java +++ b/models/spring-ai-google-genai-image/src/main/java/org/springframework/ai/google/genai/image/GoogleGenAiImageModel.java @@ -152,33 +152,6 @@ public ImageResponse call(ImagePrompt prompt) { if (Objects.nonNull(options.getMaxOutputTokens())) { configBuilder.maxOutputTokens(options.getMaxOutputTokens()); } - if (Objects.nonNull(options.getStopSequences()) && !options.getStopSequences().isEmpty()) { - configBuilder.stopSequences(options.getStopSequences()); - } - if (Objects.nonNull(options.getResponseLogprobs())) { - configBuilder.responseLogprobs(options.getResponseLogprobs()); - } - if (Objects.nonNull(options.getLogprobs())) { - configBuilder.logprobs(options.getLogprobs()); - } - if (Objects.nonNull(options.getPresencePenalty())) { - configBuilder.presencePenalty(options.getPresencePenalty()); - } - if (Objects.nonNull(options.getFrequencyPenalty())) { - configBuilder.frequencyPenalty(options.getFrequencyPenalty()); - } - if (StringUtils.hasText(options.getResponseMimeType())) { - configBuilder.responseMimeType(options.getResponseMimeType()); - } - if (StringUtils.hasText(options.getMediaResolution())) { - configBuilder.mediaResolution(options.getMediaResolution()); - } - if (StringUtils.hasText(options.getCachedContent())) { - configBuilder.cachedContent(options.getCachedContent()); - } - if (StringUtils.hasText(options.getServiceTier())) { - configBuilder.serviceTier(options.getServiceTier()); - } if (Objects.nonNull(options.getLabels()) && !options.getLabels().isEmpty()) { configBuilder.labels(options.getLabels()); } diff --git a/models/spring-ai-google-genai-image/src/main/java/org/springframework/ai/google/genai/image/GoogleGenAiImageOptions.java b/models/spring-ai-google-genai-image/src/main/java/org/springframework/ai/google/genai/image/GoogleGenAiImageOptions.java index 910e235b65..c20eb944bf 100644 --- a/models/spring-ai-google-genai-image/src/main/java/org/springframework/ai/google/genai/image/GoogleGenAiImageOptions.java +++ b/models/spring-ai-google-genai-image/src/main/java/org/springframework/ai/google/genai/image/GoogleGenAiImageOptions.java @@ -16,9 +16,7 @@ package org.springframework.ai.google.genai.image; -import java.util.ArrayList; import java.util.LinkedHashMap; -import java.util.List; import java.util.Map; import java.util.Objects; @@ -52,25 +50,15 @@ public class GoogleGenAiImageOptions implements ImageOptions { private final @Nullable Integer n; /** - * A description of what to discourage in the generated images. + * Random seed for image generation. */ - private final @Nullable String negativePrompt; + private final @Nullable Integer seed; /** * Aspect ratio of the generated images. Supported values: 1:1, 3:4, 4:3, 9:16, 16:9. */ private final @Nullable String aspectRatio; - /** - * Controls how much the model adheres to the text prompt. - */ - private final @Nullable Float guidanceScale; - - /** - * Random seed for image generation. - */ - private final @Nullable Integer seed; - /** * Filter level for safety filtering. */ @@ -81,21 +69,6 @@ public class GoogleGenAiImageOptions implements ImageOptions { */ private final @Nullable PersonGeneration personGeneration; - /** - * Whether to report the safety scores of each generated image in the response. - */ - private final @Nullable Boolean includeSafetyAttributes; - - /** - * Whether to include the Responsible AI filter reason if the image is filtered out. - */ - private final @Nullable Boolean includeRaiReason; - - /** - * Language of the text in the prompt. - */ - private final @Nullable String language; - /** * MIME type of the generated image (e.g. {@code image/png}, {@code image/jpeg}). */ @@ -117,11 +90,6 @@ public class GoogleGenAiImageOptions implements ImageOptions { */ private final @Nullable String imageSize; - /** - * Whether to use the prompt rewriting logic. - */ - private final @Nullable Boolean enhancePrompt; - /** * Controls the degree of randomness in token selection. Lower temperatures are good * for prompts that require a less open-ended or creative response, while higher @@ -146,116 +114,35 @@ public class GoogleGenAiImageOptions implements ImageOptions { */ private final @Nullable Integer maxOutputTokens; - /** - * List of strings that tells the model to stop generating text if one of the strings - * is encountered in the response. - */ - private final @Nullable List stopSequences; - - /** - * Whether to return the log probabilities of the tokens that were chosen by the model - * at each step. - */ - private final @Nullable Boolean responseLogprobs; - - /** - * Number of top candidate tokens to return the log probabilities for at each - * generation step. - */ - private final @Nullable Integer logprobs; - - /** - * Positive values penalize tokens that already appear in the generated text, - * increasing the probability of generating more diverse content. - */ - private final @Nullable Float presencePenalty; - - /** - * Positive values penalize tokens that repeatedly appear in the generated text, - * increasing the probability of generating more diverse content. - */ - private final @Nullable Float frequencyPenalty; - - /** - * Output response mime type of the generated candidate text (e.g. {@code text/plain}, - * {@code application/json}). - */ - private final @Nullable String responseMimeType; - - /** - * The media resolution to use, if specified. - */ - private final @Nullable String mediaResolution; - - /** - * Resource name of a context cache that can be used in subsequent requests. - */ - private final @Nullable String cachedContent; - - /** - * The service tier to use for the request. - */ - private final @Nullable String serviceTier; - protected GoogleGenAiImageOptions( @Nullable String model, @Nullable Integer n, - @Nullable String negativePrompt, @Nullable String aspectRatio, - @Nullable Float guidanceScale, @Nullable Integer seed, @Nullable SafetyFilterLevel safetyFilterLevel, @Nullable PersonGeneration personGeneration, - @Nullable Boolean includeSafetyAttributes, - @Nullable Boolean includeRaiReason, - @Nullable String language, @Nullable String outputMimeType, @Nullable Integer outputCompressionQuality, @Nullable Map labels, @Nullable String imageSize, - @Nullable Boolean enhancePrompt, @Nullable Float temperature, @Nullable Float topP, @Nullable Float topK, - @Nullable Integer maxOutputTokens, - @Nullable List stopSequences, - @Nullable Boolean responseLogprobs, - @Nullable Integer logprobs, - @Nullable Float presencePenalty, - @Nullable Float frequencyPenalty, - @Nullable String responseMimeType, - @Nullable String mediaResolution, - @Nullable String cachedContent, - @Nullable String serviceTier) { + @Nullable Integer maxOutputTokens) { this.model = (model != null ? model : DEFAULT_MODEL_NAME); this.n = n; - this.negativePrompt = negativePrompt; this.aspectRatio = aspectRatio; - this.guidanceScale = guidanceScale; this.seed = seed; this.safetyFilterLevel = safetyFilterLevel; this.personGeneration = personGeneration; - this.includeSafetyAttributes = includeSafetyAttributes; - this.includeRaiReason = includeRaiReason; - this.language = language; this.outputMimeType = outputMimeType; this.outputCompressionQuality = outputCompressionQuality; this.labels = (labels == null) ? null : new LinkedHashMap<>(labels); this.imageSize = imageSize; - this.enhancePrompt = enhancePrompt; this.temperature = temperature; this.topP = topP; this.topK = topK; this.maxOutputTokens = maxOutputTokens; - this.stopSequences = (stopSequences == null) ? null : new ArrayList<>(stopSequences); - this.responseLogprobs = responseLogprobs; - this.logprobs = logprobs; - this.presencePenalty = presencePenalty; - this.frequencyPenalty = frequencyPenalty; - this.responseMimeType = responseMimeType; - this.mediaResolution = mediaResolution; - this.cachedContent = cachedContent; - this.serviceTier = serviceTier; } public static GoogleGenAiImageOptions.Builder builder() { @@ -305,18 +192,10 @@ public static GoogleGenAiImageOptions.Builder builder() { return null; } - public @Nullable String getNegativePrompt() { - return this.negativePrompt; - } - public @Nullable String getAspectRatio() { return this.aspectRatio; } - public @Nullable Float getGuidanceScale() { - return this.guidanceScale; - } - public @Nullable Integer getSeed() { return this.seed; } @@ -329,18 +208,6 @@ public static GoogleGenAiImageOptions.Builder builder() { return this.personGeneration; } - public @Nullable Boolean getIncludeSafetyAttributes() { - return this.includeSafetyAttributes; - } - - public @Nullable Boolean getIncludeRaiReason() { - return this.includeRaiReason; - } - - public @Nullable String getLanguage() { - return this.language; - } - public @Nullable String getOutputMimeType() { return this.outputMimeType; } @@ -357,10 +224,6 @@ public static GoogleGenAiImageOptions.Builder builder() { return this.imageSize; } - public @Nullable Boolean getEnhancePrompt() { - return this.enhancePrompt; - } - public @Nullable Float getTemperature() { return this.temperature; } @@ -377,42 +240,6 @@ public static GoogleGenAiImageOptions.Builder builder() { return this.maxOutputTokens; } - public @Nullable List getStopSequences() { - return this.stopSequences; - } - - public @Nullable Boolean getResponseLogprobs() { - return this.responseLogprobs; - } - - public @Nullable Integer getLogprobs() { - return this.logprobs; - } - - public @Nullable Float getPresencePenalty() { - return this.presencePenalty; - } - - public @Nullable Float getFrequencyPenalty() { - return this.frequencyPenalty; - } - - public @Nullable String getResponseMimeType() { - return this.responseMimeType; - } - - public @Nullable String getMediaResolution() { - return this.mediaResolution; - } - - public @Nullable String getCachedContent() { - return this.cachedContent; - } - - public @Nullable String getServiceTier() { - return this.serviceTier; - } - /** * Safety filter level for image generation. */ @@ -437,24 +264,14 @@ public static final class Builder { private @Nullable Integer n; - private @Nullable String negativePrompt; - private @Nullable String aspectRatio; - private @Nullable Float guidanceScale; - private @Nullable Integer seed; private @Nullable SafetyFilterLevel safetyFilterLevel; private @Nullable PersonGeneration personGeneration; - private @Nullable Boolean includeSafetyAttributes; - - private @Nullable Boolean includeRaiReason; - - private @Nullable String language; - private @Nullable String outputMimeType; private @Nullable Integer outputCompressionQuality; @@ -463,8 +280,6 @@ public static final class Builder { private @Nullable String imageSize; - private @Nullable Boolean enhancePrompt; - private @Nullable Float temperature; private @Nullable Float topP; @@ -473,24 +288,6 @@ public static final class Builder { private @Nullable Integer maxOutputTokens; - private @Nullable List stopSequences; - - private @Nullable Boolean responseLogprobs; - - private @Nullable Integer logprobs; - - private @Nullable Float presencePenalty; - - private @Nullable Float frequencyPenalty; - - private @Nullable String responseMimeType; - - private @Nullable String mediaResolution; - - private @Nullable String cachedContent; - - private @Nullable String serviceTier; - public Builder() { } @@ -501,15 +298,9 @@ public Builder from(GoogleGenAiImageOptions fromOptions) { if (Objects.nonNull(fromOptions.getN())) { this.n = fromOptions.getN(); } - if (StringUtils.hasText(fromOptions.getNegativePrompt())) { - this.negativePrompt = fromOptions.getNegativePrompt(); - } if (StringUtils.hasText(fromOptions.getAspectRatio())) { this.aspectRatio = fromOptions.getAspectRatio(); } - if (Objects.nonNull(fromOptions.getGuidanceScale())) { - this.guidanceScale = fromOptions.getGuidanceScale(); - } if (Objects.nonNull(fromOptions.getSeed())) { this.seed = fromOptions.getSeed(); } @@ -519,15 +310,6 @@ public Builder from(GoogleGenAiImageOptions fromOptions) { if (Objects.nonNull(fromOptions.getPersonGeneration())) { this.personGeneration = fromOptions.getPersonGeneration(); } - if (Objects.nonNull(fromOptions.getIncludeSafetyAttributes())) { - this.includeSafetyAttributes = fromOptions.getIncludeSafetyAttributes(); - } - if (Objects.nonNull(fromOptions.getIncludeRaiReason())) { - this.includeRaiReason = fromOptions.getIncludeRaiReason(); - } - if (StringUtils.hasText(fromOptions.getLanguage())) { - this.language = fromOptions.getLanguage(); - } if (StringUtils.hasText(fromOptions.getOutputMimeType())) { this.outputMimeType = fromOptions.getOutputMimeType(); } @@ -541,10 +323,6 @@ public Builder from(GoogleGenAiImageOptions fromOptions) { this.imageSize = fromOptions.getImageSize(); } - if (Objects.nonNull(fromOptions.getEnhancePrompt())) { - this.enhancePrompt = fromOptions.getEnhancePrompt(); - } - if (Objects.nonNull(fromOptions.getTemperature())) { this.temperature = fromOptions.getTemperature(); } @@ -557,33 +335,6 @@ public Builder from(GoogleGenAiImageOptions fromOptions) { if (Objects.nonNull(fromOptions.getMaxOutputTokens())) { this.maxOutputTokens = fromOptions.getMaxOutputTokens(); } - if (Objects.nonNull(fromOptions.getStopSequences())) { - this.stopSequences = fromOptions.getStopSequences(); - } - if (Objects.nonNull(fromOptions.getResponseLogprobs())) { - this.responseLogprobs = fromOptions.getResponseLogprobs(); - } - if (Objects.nonNull(fromOptions.getLogprobs())) { - this.logprobs = fromOptions.getLogprobs(); - } - if (Objects.nonNull(fromOptions.getPresencePenalty())) { - this.presencePenalty = fromOptions.getPresencePenalty(); - } - if (Objects.nonNull(fromOptions.getFrequencyPenalty())) { - this.frequencyPenalty = fromOptions.getFrequencyPenalty(); - } - if (StringUtils.hasText(fromOptions.getResponseMimeType())) { - this.responseMimeType = fromOptions.getResponseMimeType(); - } - if (StringUtils.hasText(fromOptions.getMediaResolution())) { - this.mediaResolution = fromOptions.getMediaResolution(); - } - if (StringUtils.hasText(fromOptions.getCachedContent())) { - this.cachedContent = fromOptions.getCachedContent(); - } - if (StringUtils.hasText(fromOptions.getServiceTier())) { - this.serviceTier = fromOptions.getServiceTier(); - } return this; } @@ -603,21 +354,11 @@ public Builder n(@Nullable Integer n) { return this; } - public Builder negativePrompt(@Nullable String negativePrompt) { - this.negativePrompt = negativePrompt; - return this; - } - public Builder aspectRatio(@Nullable String aspectRatio) { this.aspectRatio = aspectRatio; return this; } - public Builder guidanceScale(@Nullable Float guidanceScale) { - this.guidanceScale = guidanceScale; - return this; - } - public Builder seed(@Nullable Integer seed) { this.seed = seed; return this; @@ -633,21 +374,6 @@ public Builder personGeneration(@Nullable PersonGeneration personGeneration) { return this; } - public Builder includeSafetyAttributes(@Nullable Boolean includeSafetyAttributes) { - this.includeSafetyAttributes = includeSafetyAttributes; - return this; - } - - public Builder includeRaiReason(@Nullable Boolean includeRaiReason) { - this.includeRaiReason = includeRaiReason; - return this; - } - - public Builder language(@Nullable String language) { - this.language = language; - return this; - } - public Builder outputMimeType(@Nullable String outputMimeType) { this.outputMimeType = outputMimeType; return this; @@ -668,11 +394,6 @@ public Builder imageSize(@Nullable String imageSize) { return this; } - public Builder enhancePrompt(@Nullable Boolean enhancePrompt) { - this.enhancePrompt = enhancePrompt; - return this; - } - public Builder temperature(@Nullable Float temperature) { this.temperature = temperature; return this; @@ -693,59 +414,10 @@ public Builder maxOutputTokens(@Nullable Integer maxOutputTokens) { return this; } - public Builder stopSequences(@Nullable List stopSequences) { - this.stopSequences = stopSequences; - return this; - } - - public Builder responseLogprobs(@Nullable Boolean responseLogprobs) { - this.responseLogprobs = responseLogprobs; - return this; - } - - public Builder logprobs(@Nullable Integer logprobs) { - this.logprobs = logprobs; - return this; - } - - public Builder presencePenalty(@Nullable Float presencePenalty) { - this.presencePenalty = presencePenalty; - return this; - } - - public Builder frequencyPenalty(@Nullable Float frequencyPenalty) { - this.frequencyPenalty = frequencyPenalty; - return this; - } - - public Builder responseMimeType(@Nullable String responseMimeType) { - this.responseMimeType = responseMimeType; - return this; - } - - public Builder mediaResolution(@Nullable String mediaResolution) { - this.mediaResolution = mediaResolution; - return this; - } - - public Builder cachedContent(@Nullable String cachedContent) { - this.cachedContent = cachedContent; - return this; - } - - public Builder serviceTier(@Nullable String serviceTier) { - this.serviceTier = serviceTier; - return this; - } - public GoogleGenAiImageOptions build() { - return new GoogleGenAiImageOptions(this.model, this.n, this.negativePrompt, this.aspectRatio, - this.guidanceScale, this.seed, this.safetyFilterLevel, this.personGeneration, - this.includeSafetyAttributes, this.includeRaiReason, this.language, this.outputMimeType, - this.outputCompressionQuality, this.labels, this.imageSize, this.enhancePrompt, this.temperature, - this.topP, this.topK, this.maxOutputTokens, this.stopSequences, this.responseLogprobs, - this.logprobs, this.presencePenalty, this.frequencyPenalty, this.responseMimeType, - this.mediaResolution, this.cachedContent, this.serviceTier); + return new GoogleGenAiImageOptions(this.model, this.n, this.aspectRatio, this.seed, this.safetyFilterLevel, + this.personGeneration, this.outputMimeType, this.outputCompressionQuality, this.labels, + this.imageSize, this.temperature, this.topP, this.topK, this.maxOutputTokens); } } diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/image/google-genai-image.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/image/google-genai-image.adoc index 1d60df331d..b0bf10b7c6 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/image/google-genai-image.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/image/google-genai-image.adoc @@ -115,33 +115,18 @@ The prefix `spring.ai.google.genai.image` is the property prefix that lets you c | spring.ai.google.genai.image.options.n | The number of images to generate. Must be between 1 and 4. | 4 | spring.ai.google.genai.image.options.model | The https://docs.cloud.google.com/gemini-enterprise-agent-platform/models[Google GenAI model] to use. Supported models include `gemini-2.5-flash-image`, `gemini-3-pro-image` and `gemini-3.1-flash-image` | - -| spring.ai.google.genai.image.options.negative-prompt | A description of what to discourage in the generated images | - | spring.ai.google.genai.image.options.aspect-ratio | The aspect ratio of the generated images. Supported values are 1:1, 3:4, 4:3, 9:16, and 16:9 | 1:1 -| spring.ai.google.genai.image.options.guidance-scale | Controls how much the model adheres to the text prompt. Large values increase output and prompt alignment, but may compromise image quality. | - | spring.ai.google.genai.image.options.seed | Random seed for image generation. This is not available when `add-watermark` is set to true. | - | spring.ai.google.genai.image.options.safety-filter-level | Filter level for safety filtering. Supported values are BLOCK_LOW_AND_ABOVE, BLOCK_MEDIUM_AND_ABOVE, BLOCK_ONLY_HIGH and BLOCK_NONE. | - | spring.ai.google.genai.image.options.person-generation | Allows generation of people by the model. Supported values are DONT_ALLOW, ALLOW_ADULT and ALLOW_ALL. | - -| spring.ai.google.genai.image.options.include-safety-attributes | Whether to report the safety scores of each generated image and the positive prompt in the response. | - -| spring.ai.google.genai.image.options.include-rai-reason | Whether to include the Responsible AI filter reason if the image is filtered out of the response. | - -| spring.ai.google.genai.image.options.language | Language of the text in the prompt. | - | spring.ai.google.genai.image.options.output-mime-type | MIME type of the generated image. | - | spring.ai.google.genai.image.options.output-compression-quality | Compression quality of the generated image (for `image/jpeg` only). | - | spring.ai.google.genai.image.options.labels | User specified labels to track billing usage. | - | spring.ai.google.genai.image.options.image-size | The size of the largest dimension of the generated image. Supported sizes are 1K and 2K. | - -| spring.ai.google.genai.image.options.enhance-prompt | Whether to use the prompt rewriting logic. | - | spring.ai.google.genai.image.options.temperature | Controls the degree of randomness in token selection. Lower values produce less open-ended responses, higher values produce more diverse or creative results. | - | spring.ai.google.genai.image.options.top-p | Tokens are selected from the most to least probable until the sum of their probabilities equals this value. | - | spring.ai.google.genai.image.options.top-k | For each token selection step, the `top-k` tokens with the highest probabilities are sampled. | - | spring.ai.google.genai.image.options.max-output-tokens | Maximum number of tokens that can be generated in the response. | - -| spring.ai.google.genai.image.options.stop-sequences | List of strings that tells the model to stop generating text if one of the strings is encountered in the response. | - -| spring.ai.google.genai.image.options.response-logprobs | Whether to return the log probabilities of the tokens that were chosen by the model at each step. | - -| spring.ai.google.genai.image.options.logprobs | Number of top candidate tokens to return the log probabilities for at each generation step. | - -| spring.ai.google.genai.image.options.presence-penalty | Positive values penalize tokens that already appear in the generated text, increasing the probability of generating more diverse content. | - -| spring.ai.google.genai.image.options.frequency-penalty | Positive values penalize tokens that repeatedly appear in the generated text, increasing the probability of generating more diverse content. | - -| spring.ai.google.genai.image.options.response-mime-type | Output response mime type of the generated candidate text (e.g. `text/plain`, `application/json`). | - -| spring.ai.google.genai.image.options.media-resolution | The media resolution to use, if specified. | - -| spring.ai.google.genai.image.options.cached-content | Resource name of a context cache that can be used in subsequent requests. | - -| spring.ai.google.genai.image.options.service-tier | The service tier to use for the request. | - |==== From c939b513ef49e901479531ad978c34f3f954abf5 Mon Sep 17 00:00:00 2001 From: Olivier LE-QUELLEC Date: Sat, 6 Jun 2026 18:02:18 +0200 Subject: [PATCH 4/4] refacto with streams Signed-off-by: Olivier LE-QUELLEC --- .../genai/image/GoogleGenAiImageModel.java | 52 ++++++++----------- 1 file changed, 23 insertions(+), 29 deletions(-) diff --git a/models/spring-ai-google-genai-image/src/main/java/org/springframework/ai/google/genai/image/GoogleGenAiImageModel.java b/models/spring-ai-google-genai-image/src/main/java/org/springframework/ai/google/genai/image/GoogleGenAiImageModel.java index 484958d8f0..eb9fcc4be1 100644 --- a/models/spring-ai-google-genai-image/src/main/java/org/springframework/ai/google/genai/image/GoogleGenAiImageModel.java +++ b/models/spring-ai-google-genai-image/src/main/java/org/springframework/ai/google/genai/image/GoogleGenAiImageModel.java @@ -23,7 +23,6 @@ import java.util.Optional; import com.google.genai.Client; -import com.google.genai.types.Blob; import com.google.genai.types.Candidate; import com.google.genai.types.Content; import com.google.genai.types.GenerateContentConfig; @@ -205,34 +204,29 @@ public ImageResponse call(ImagePrompt prompt) { // Process the response: each candidate may contain multiple content // parts, so add an ImageGeneration for every part that carries image // data. - final List generationList = new ArrayList<>(); - if (imagesResponse.candidates().isPresent()) { - for (Candidate candidate : imagesResponse.candidates().get()) { - Optional content = candidate.content(); - if (content.isEmpty() || content.get().parts().isEmpty()) { - continue; - } - - for (Part part : content.get().parts().get()) { - Optional inlineData = part.inlineData(); - if (inlineData.isEmpty()) { - continue; - } - - Blob blob = inlineData.get(); - String b64Json = blob.data() - .map(imageBytes -> Base64.getEncoder().encodeToString(imageBytes)) - .orElse(null); - - Image image = new Image(null, b64Json); - - GoogleGenAiImageGenerationMetadata metadata = new GoogleGenAiImageGenerationMetadata(null, - null, blob.mimeType().orElse(null), image.getUrl()); - - generationList.add(new ImageGeneration(image, metadata)); - } - } - } + final List generationList = imagesResponse.candidates() + .stream() + .flatMap(List::stream) + .map(Candidate::content) + .flatMap(Optional::stream) + .map(Content::parts) + .flatMap(Optional::stream) + .flatMap(List::stream) + .map(Part::inlineData) + .flatMap(Optional::stream) + .map(blob -> { + String b64Json = blob.data() + .map(imageBytes -> Base64.getEncoder().encodeToString(imageBytes)) + .orElse(null); + + Image image = new Image(null, b64Json); + + GoogleGenAiImageGenerationMetadata metadata = new GoogleGenAiImageGenerationMetadata(null, null, + blob.mimeType().orElse(null), image.getUrl()); + + return new ImageGeneration(image, metadata); + }) + .toList(); ImageResponse response = new ImageResponse(generationList, new ImageResponseMetadata());