diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiAutoConfiguration.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiAutoConfiguration.java index 7c28e39ea..29a7aebb3 100644 --- a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiAutoConfiguration.java +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiAutoConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2023 the original author or authors. + * Copyright 2023-2024 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. @@ -20,7 +20,9 @@ import org.springframework.ai.autoconfigure.NativeHints; import org.springframework.ai.embedding.EmbeddingClient; import org.springframework.ai.openai.OpenAiChatClient; import org.springframework.ai.openai.OpenAiEmbeddingClient; +import org.springframework.ai.openai.OpenAiImageClient; import org.springframework.ai.openai.api.OpenAiApi; +import org.springframework.ai.openai.api.OpenAiImageApi; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; @@ -34,7 +36,7 @@ import org.springframework.web.client.RestClient; @AutoConfiguration @ConditionalOnClass(OpenAiApi.class) @EnableConfigurationProperties({ OpenAiConnectionProperties.class, OpenAiChatProperties.class, - OpenAiEmbeddingProperties.class }) + OpenAiEmbeddingProperties.class, OpenAiImageProperties.class }) @ImportRuntimeHints(NativeHints.class) public class OpenAiAutoConfiguration { @@ -78,4 +80,22 @@ public class OpenAiAutoConfiguration { return new OpenAiEmbeddingClient(openAiApi).withDefaultOptions(embeddingProperties.getOptions()); } + @Bean + @ConditionalOnMissingBean + public OpenAiImageClient openAiImageClient(OpenAiConnectionProperties commonProperties, + OpenAiImageProperties imageProperties) { + String apiKey = StringUtils.hasText(imageProperties.getApiKey()) ? imageProperties.getApiKey() + : commonProperties.getApiKey(); + + String baseUrl = StringUtils.hasText(imageProperties.getBaseUrl()) ? imageProperties.getBaseUrl() + : commonProperties.getBaseUrl(); + + Assert.hasText(apiKey, "OpenAI API key must be set"); + Assert.hasText(baseUrl, "OpenAI base URL must be set"); + + var openAiImageApi = new OpenAiImageApi(baseUrl, apiKey, RestClient.builder()); + + return new OpenAiImageClient(openAiImageApi).withDefaultOptions(imageProperties.getOptions()); + } + } diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiImageProperties.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiImageProperties.java new file mode 100644 index 000000000..542420512 --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiImageProperties.java @@ -0,0 +1,48 @@ +/* + * Copyright 2024 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.autoconfigure.openai; + +import org.springframework.ai.openai.OpenAiImageOptions; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; + +/** + * OpenAI Image autoconfiguration properties. + * + * @author Thomas Vitale + * @since 0.8.0 + */ +@ConfigurationProperties(OpenAiImageProperties.CONFIG_PREFIX) +public class OpenAiImageProperties extends OpenAiParentProperties { + + public static final String CONFIG_PREFIX = "spring.ai.openai.image"; + + /** + * Options for OpenAI Image API. + */ + @NestedConfigurationProperty + private OpenAiImageOptions options = OpenAiImageOptions.builder().build(); + + public OpenAiImageOptions getOptions() { + return options; + } + + public void setOptions(OpenAiImageOptions options) { + this.options = options; + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/OpenAiAutoConfigurationIT.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/OpenAiAutoConfigurationIT.java index b5dd0a430..6827fb233 100644 --- a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/OpenAiAutoConfigurationIT.java +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/OpenAiAutoConfigurationIT.java @@ -1,5 +1,5 @@ /* - * Copyright 2023-2023 the original author or authors. + * Copyright 2023-2024 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. @@ -25,6 +25,9 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.image.ImagePrompt; +import org.springframework.ai.image.ImageResponse; +import org.springframework.ai.openai.OpenAiImageClient; import reactor.core.publisher.Flux; import org.springframework.ai.chat.ChatResponse; @@ -86,4 +89,15 @@ public class OpenAiAutoConfigurationIT { }); } + @Test + void generateImage() { + contextRunner.withPropertyValues("spring.ai.openai.image.options.size=256x256").run(context -> { + OpenAiImageClient client = context.getBean(OpenAiImageClient.class); + ImageResponse imageResponse = client.call(new ImagePrompt("forest")); + assertThat(imageResponse.getResults()).hasSize(1); + assertThat(imageResponse.getResult().getOutput().getUrl()).isNotEmpty(); + logger.info("Generated image: " + imageResponse.getResult().getOutput().getUrl()); + }); + } + } diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/OpenAiPropertiesTests.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/OpenAiPropertiesTests.java index 7fb7b938e..c411a2e52 100644 --- a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/OpenAiPropertiesTests.java +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/OpenAiPropertiesTests.java @@ -33,6 +33,7 @@ import static org.assertj.core.api.Assertions.assertThat; * {@link OpenAiEmbeddingProperties}. * * @author Christian Tzolov + * @author Thomas Vitale * @since 0.8.0 */ public class OpenAiPropertiesTests { @@ -141,6 +142,58 @@ public class OpenAiPropertiesTests { }); } + @Test + public void imageProperties() { + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.openai.base-url=TEST_BASE_URL", + "spring.ai.openai.api-key=abc123", + "spring.ai.openai.image.options.model=MODEL_XYZ", + "spring.ai.openai.image.options.n=3") + // @formatter:on + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) + .run(context -> { + var imageProperties = context.getBean(OpenAiImageProperties.class); + var connectionProperties = context.getBean(OpenAiConnectionProperties.class); + + assertThat(connectionProperties.getApiKey()).isEqualTo("abc123"); + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + + assertThat(imageProperties.getApiKey()).isNull(); + assertThat(imageProperties.getBaseUrl()).isNull(); + + assertThat(imageProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + assertThat(imageProperties.getOptions().getN()).isEqualTo(3); + }); + } + + @Test + public void imageOverrideConnectionProperties() { + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.openai.base-url=TEST_BASE_URL", + "spring.ai.openai.api-key=abc123", + "spring.ai.openai.image.base-url=TEST_BASE_URL2", + "spring.ai.openai.image.api-key=456", + "spring.ai.openai.image.options.model=MODEL_XYZ", + "spring.ai.openai.image.options.n=3") + // @formatter:on + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) + .run(context -> { + var imageProperties = context.getBean(OpenAiImageProperties.class); + var connectionProperties = context.getBean(OpenAiConnectionProperties.class); + + assertThat(connectionProperties.getApiKey()).isEqualTo("abc123"); + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + + assertThat(imageProperties.getApiKey()).isEqualTo("456"); + assertThat(imageProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL2"); + + assertThat(imageProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + assertThat(imageProperties.getOptions().getN()).isEqualTo(3); + }); + } + @Test public void chatOptionsTest() { @@ -256,4 +309,42 @@ public class OpenAiPropertiesTests { }); } + @Test + public void imageOptionsTest() { + new ApplicationContextRunner().withPropertyValues( + // @formatter:off + "spring.ai.openai.api-key=API_KEY", + "spring.ai.openai.base-url=TEST_BASE_URL", + + "spring.ai.openai.image.options.n=3", + "spring.ai.openai.image.options.model=MODEL_XYZ", + "spring.ai.openai.image.options.quality=hd", + "spring.ai.openai.image.options.response_format=url", + "spring.ai.openai.image.options.size=1024x1024", + "spring.ai.openai.image.options.width=1024", + "spring.ai.openai.image.options.height=1024", + "spring.ai.openai.image.options.style=vivid", + "spring.ai.openai.image.options.user=userXYZ" + ) + // @formatter:on + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) + .run(context -> { + var imageProperties = context.getBean(OpenAiImageProperties.class); + var connectionProperties = context.getBean(OpenAiConnectionProperties.class); + + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + assertThat(connectionProperties.getApiKey()).isEqualTo("API_KEY"); + + assertThat(imageProperties.getOptions().getN()).isEqualTo(3); + assertThat(imageProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + assertThat(imageProperties.getOptions().getQuality()).isEqualTo("hd"); + assertThat(imageProperties.getOptions().getResponseFormat()).isEqualTo("url"); + assertThat(imageProperties.getOptions().getSize()).isEqualTo("1024x1024"); + assertThat(imageProperties.getOptions().getWidth()).isEqualTo(1024); + assertThat(imageProperties.getOptions().getHeight()).isEqualTo(1024); + assertThat(imageProperties.getOptions().getStyle()).isEqualTo("vivid"); + assertThat(imageProperties.getOptions().getUser()).isEqualTo("userXYZ"); + }); + } + }