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..ffb5b3ea94 --- /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,196 @@ +/* + * 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.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 aspectRatio = GoogleGenAiImageOptions.DEFAULT_ASPECT_RATIO; + + private @Nullable Integer seed; + + private GoogleGenAiImageOptions.@Nullable SafetyFilterLevel safetyFilterLevel; + + private GoogleGenAiImageOptions.@Nullable PersonGeneration personGeneration; + + private @Nullable String outputMimeType; + + private @Nullable Integer outputCompressionQuality; + + private @Nullable Map labels; + + private @Nullable String imageSize; + + private @Nullable Float temperature; + + private @Nullable Float topP; + + private @Nullable Float topK; + + private @Nullable Integer maxOutputTokens; + + 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 getAspectRatio() { + return this.aspectRatio; + } + + public void setAspectRatio(@Nullable String aspectRatio) { + this.aspectRatio = aspectRatio; + } + + 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 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 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 GoogleGenAiImageOptions toOptions() { + return GoogleGenAiImageOptions.builder() + .model(this.model) + .n(this.n) + .aspectRatio(this.aspectRatio) + .seed(this.seed) + .safetyFilterLevel(this.safetyFilterLevel) + .personGeneration(this.personGeneration) + .outputMimeType(this.outputMimeType) + .outputCompressionQuality(this.outputCompressionQuality) + .labels(this.labels) + .imageSize(this.imageSize) + .temperature(this.temperature) + .topP(this.topP) + .topK(this.topK) + .maxOutputTokens(this.maxOutputTokens) + .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..16bd2962e3 --- /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 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..31a3ef60de --- /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,103 @@ +/* + * 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=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.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.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.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.getSeed()).isEqualTo(42); + assertThat(options.getSafetyFilterLevel()) + .isEqualTo(GoogleGenAiImageOptions.SafetyFilterLevel.BLOCK_ONLY_HIGH); + assertThat(options.getPersonGeneration()) + .isEqualTo(GoogleGenAiImageOptions.PersonGeneration.ALLOW_ADULT); + assertThat(options.getOutputMimeType()).isEqualTo("image/png"); + assertThat(options.getOutputCompressionQuality()).isEqualTo(80); + assertThat(options.getImageSize()).isEqualTo("2K"); + 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); + }); + } + + @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..e65d42a416 --- /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 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..eb9fcc4be1 --- /dev/null +++ b/models/spring-ai-google-genai-image/src/main/java/org/springframework/ai/google/genai/image/GoogleGenAiImageModel.java @@ -0,0 +1,295 @@ +/* + * 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.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.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 = 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()); + + 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..dfad252507 --- /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 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..c20eb944bf --- /dev/null +++ b/models/spring-ai-google-genai-image/src/main/java/org/springframework/ai/google/genai/image/GoogleGenAiImageOptions.java @@ -0,0 +1,425 @@ +/* + * 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.LinkedHashMap; +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; + + /** + * Random seed for image generation. + */ + 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; + + /** + * Filter level for safety filtering. + */ + private final @Nullable SafetyFilterLevel safetyFilterLevel; + + /** + * Allows generation of people by the model. + */ + private final @Nullable PersonGeneration personGeneration; + + /** + * 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; + + /** + * 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; + + protected GoogleGenAiImageOptions( + @Nullable String model, + @Nullable Integer n, + @Nullable String aspectRatio, + @Nullable Integer seed, + @Nullable SafetyFilterLevel safetyFilterLevel, + @Nullable PersonGeneration personGeneration, + @Nullable String outputMimeType, + @Nullable Integer outputCompressionQuality, + @Nullable Map labels, + @Nullable String imageSize, + @Nullable Float temperature, + @Nullable Float topP, + @Nullable Float topK, + @Nullable Integer maxOutputTokens) { + this.model = (model != null ? model : DEFAULT_MODEL_NAME); + this.n = n; + this.aspectRatio = aspectRatio; + this.seed = seed; + this.safetyFilterLevel = safetyFilterLevel; + this.personGeneration = personGeneration; + this.outputMimeType = outputMimeType; + this.outputCompressionQuality = outputCompressionQuality; + this.labels = (labels == null) ? null : new LinkedHashMap<>(labels); + this.imageSize = imageSize; + this.temperature = temperature; + this.topP = topP; + this.topK = topK; + this.maxOutputTokens = maxOutputTokens; + } + + 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 getAspectRatio() { + return this.aspectRatio; + } + + public @Nullable Integer getSeed() { + return this.seed; + } + + public @Nullable SafetyFilterLevel getSafetyFilterLevel() { + return this.safetyFilterLevel; + } + + public @Nullable PersonGeneration getPersonGeneration() { + return this.personGeneration; + } + + 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 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; + } + + /** + * 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 aspectRatio; + + private @Nullable Integer seed; + + private @Nullable SafetyFilterLevel safetyFilterLevel; + + private @Nullable PersonGeneration personGeneration; + + private @Nullable String outputMimeType; + + private @Nullable Integer outputCompressionQuality; + + private @Nullable Map labels; + + private @Nullable String imageSize; + + private @Nullable Float temperature; + + private @Nullable Float topP; + + private @Nullable Float topK; + + private @Nullable Integer maxOutputTokens; + + 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.getAspectRatio())) { + this.aspectRatio = fromOptions.getAspectRatio(); + } + 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 (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.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(); + } + + 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 aspectRatio(@Nullable String aspectRatio) { + this.aspectRatio = aspectRatio; + 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 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 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 GoogleGenAiImageOptions build() { + 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/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..eacb919379 --- /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 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 59776f0b5b..3648972f77 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 98cce8c9af..89b833dd6d 100644 --- a/spring-ai-bom/pom.xml +++ b/spring-ai-bom/pom.xml @@ -292,6 +292,12 @@ ${project.version} + + org.springframework.ai + spring-ai-google-genai-image + ${project.version} + + org.springframework.ai spring-ai-mistral-ai @@ -1015,6 +1021,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..b0bf10b7c6 --- /dev/null +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/image/google-genai-image.adoc @@ -0,0 +1,264 @@ += 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 image 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. + +=== 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 image generation 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 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. +==== + +==== 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. + +[cols="3,5,1", stripes=even] +|==== +| Property | Description | Default + +| 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` | - +| 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.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.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.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. | - + +|==== + +== 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=gemini-2.5-flash-image +---- + +=== 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=gemini-2.5-flash-image +---- + + +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} + + + +