Skip to content

Commit 811e294

Browse files
committed
Add support for OpenAI's Create Image Edit API
Related issue: #2870 Signed-off-by: minsoo.nam <[email protected]>
1 parent 161c437 commit 811e294

File tree

14 files changed

+1086
-4
lines changed

14 files changed

+1086
-4
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/*
2+
* Copyright 2023-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.ai.model.openai.autoconfigure;
18+
19+
import static org.springframework.ai.model.openai.autoconfigure.OpenAIAutoConfigurationUtil.resolveConnectionProperties;
20+
21+
import org.springframework.ai.image.observation.ImageModelObservationConvention;
22+
import org.springframework.ai.model.SimpleApiKey;
23+
import org.springframework.ai.model.SpringAIModelProperties;
24+
import org.springframework.ai.model.SpringAIModels;
25+
import org.springframework.ai.openai.OpenAiImageEditModel;
26+
import org.springframework.ai.openai.OpenAiImageModel;
27+
import org.springframework.ai.openai.api.OpenAiApi;
28+
import org.springframework.ai.openai.api.OpenAiImageApi;
29+
import org.springframework.ai.retry.autoconfigure.SpringAiRetryAutoConfiguration;
30+
import org.springframework.beans.factory.ObjectProvider;
31+
import org.springframework.boot.autoconfigure.AutoConfiguration;
32+
import org.springframework.boot.autoconfigure.ImportAutoConfiguration;
33+
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
34+
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
35+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
36+
import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration;
37+
import org.springframework.boot.autoconfigure.web.reactive.function.client.WebClientAutoConfiguration;
38+
import org.springframework.boot.context.properties.EnableConfigurationProperties;
39+
import org.springframework.context.annotation.Bean;
40+
import org.springframework.retry.support.RetryTemplate;
41+
import org.springframework.web.client.ResponseErrorHandler;
42+
import org.springframework.web.client.RestClient;
43+
44+
import io.micrometer.observation.ObservationRegistry;
45+
46+
/**
47+
* Image {@link AutoConfiguration Auto-configuration} for OpenAI.
48+
*
49+
* @author Minsoo Nam
50+
*/
51+
@AutoConfiguration(after = { RestClientAutoConfiguration.class, WebClientAutoConfiguration.class,
52+
SpringAiRetryAutoConfiguration.class })
53+
@ConditionalOnClass(OpenAiApi.class)
54+
@ConditionalOnProperty(name = SpringAIModelProperties.IMAGE_EDIT_MODEL, havingValue = SpringAIModels.OPENAI,
55+
matchIfMissing = true)
56+
@EnableConfigurationProperties({ OpenAiConnectionProperties.class, OpenAiImageEditProperties.class })
57+
@ImportAutoConfiguration(classes = { SpringAiRetryAutoConfiguration.class, RestClientAutoConfiguration.class,
58+
WebClientAutoConfiguration.class })
59+
public class OpenAiImageEditAutoConfiguration {
60+
61+
@Bean
62+
@ConditionalOnMissingBean
63+
public OpenAiImageEditModel openAiImageEditModel(OpenAiConnectionProperties commonProperties,
64+
OpenAiImageEditProperties imageEditProperties, ObjectProvider<RestClient.Builder> restClientBuilderProvider,
65+
RetryTemplate retryTemplate, ResponseErrorHandler responseErrorHandler) {
66+
67+
OpenAIAutoConfigurationUtil.ResolvedConnectionProperties resolved = resolveConnectionProperties(
68+
commonProperties, imageEditProperties, "image");
69+
70+
var openAiImageApi = OpenAiImageApi.builder()
71+
.baseUrl(resolved.baseUrl())
72+
.apiKey(new SimpleApiKey(resolved.apiKey()))
73+
.headers(resolved.headers())
74+
.restClientBuilder(restClientBuilderProvider.getIfAvailable(RestClient::builder))
75+
.responseErrorHandler(responseErrorHandler)
76+
.build();
77+
var imageModel = new OpenAiImageEditModel(openAiImageApi, imageEditProperties.getOptions(), retryTemplate);
78+
return imageModel;
79+
}
80+
81+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/*
2+
* Copyright 2023-2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.ai.model.openai.autoconfigure;
18+
19+
import org.springframework.ai.openai.OpenAiImageEditOptions;
20+
import org.springframework.ai.openai.OpenAiImageOptions;
21+
import org.springframework.ai.openai.api.OpenAiImageApi;
22+
import org.springframework.boot.context.properties.ConfigurationProperties;
23+
import org.springframework.boot.context.properties.NestedConfigurationProperty;
24+
25+
/**
26+
* OpenAI Image autoconfiguration properties.
27+
*
28+
* @author Minsoo Nam
29+
* @since 1.0.0
30+
*/
31+
@ConfigurationProperties(OpenAiImageEditProperties.CONFIG_PREFIX)
32+
public class OpenAiImageEditProperties extends OpenAiParentProperties {
33+
34+
public static final String CONFIG_PREFIX = "spring.ai.openai.image.edit";
35+
36+
public static final String DEFAULT_IMAGE_MODEL = OpenAiImageApi.ImageModel.DALL_E_2.getValue();
37+
38+
/**
39+
* Options for OpenAI Image Edit API.
40+
*/
41+
@NestedConfigurationProperty
42+
private OpenAiImageEditOptions options = OpenAiImageEditOptions.builder().model(DEFAULT_IMAGE_MODEL).build();
43+
44+
public OpenAiImageEditOptions getOptions() {
45+
return this.options;
46+
}
47+
48+
public void setOptions(OpenAiImageEditOptions options) {
49+
this.options = options;
50+
}
51+
52+
}

auto-configurations/models/spring-ai-autoconfigure-model-openai/src/test/java/org/springframework/ai/model/openai/autoconfigure/OpenAiModelConfigurationTests.java

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import org.springframework.ai.openai.OpenAiAudioTranscriptionModel;
2323
import org.springframework.ai.openai.OpenAiChatModel;
2424
import org.springframework.ai.openai.OpenAiEmbeddingModel;
25+
import org.springframework.ai.openai.OpenAiImageEditModel;
2526
import org.springframework.ai.openai.OpenAiImageModel;
2627
import org.springframework.ai.openai.OpenAiModerationModel;
2728
import org.springframework.boot.autoconfigure.AutoConfigurations;
@@ -33,6 +34,7 @@
3334
* Unit Tests for OpenAI auto configurations' conditional enabling of models.
3435
*
3536
* @author Ilayaperumal Gopinathan
37+
* @author Minsoo Nam
3638
*/
3739
public class OpenAiModelConfigurationTests {
3840

@@ -171,6 +173,54 @@ void imageModelActivation() {
171173
});
172174
}
173175

176+
@Test
177+
void imageEditModelActivation() {
178+
this.contextRunner.withConfiguration(AutoConfigurations.of(OpenAiImageEditAutoConfiguration.class))
179+
.run(context -> {
180+
assertThat(context.getBeansOfType(OpenAiChatModel.class)).isEmpty();
181+
assertThat(context.getBeansOfType(OpenAiEmbeddingModel.class)).isEmpty();
182+
assertThat(context.getBeansOfType(OpenAiImageModel.class)).isEmpty();
183+
assertThat(context.getBeansOfType(OpenAiImageEditModel.class)).isNotEmpty();
184+
assertThat(context.getBeansOfType(OpenAiAudioSpeechModel.class)).isEmpty();
185+
assertThat(context.getBeansOfType(OpenAiAudioTranscriptionModel.class)).isEmpty();
186+
assertThat(context.getBeansOfType(OpenAiModerationModel.class)).isEmpty();
187+
});
188+
189+
this.contextRunner.withConfiguration(AutoConfigurations.of(OpenAiImageEditAutoConfiguration.class))
190+
.withPropertyValues("spring.ai.model.image.edit=none")
191+
.run(context -> {
192+
assertThat(context.getBeansOfType(OpenAiImageEditProperties.class)).isEmpty();
193+
assertThat(context.getBeansOfType(OpenAiImageEditModel.class)).isEmpty();
194+
});
195+
196+
this.contextRunner.withConfiguration(AutoConfigurations.of(OpenAiImageEditAutoConfiguration.class))
197+
.withPropertyValues("spring.ai.model.image.edit=openai")
198+
.run(context -> {
199+
assertThat(context.getBeansOfType(OpenAiImageEditProperties.class)).isNotEmpty();
200+
assertThat(context.getBeansOfType(OpenAiImageEditModel.class)).isNotEmpty();
201+
});
202+
203+
this.contextRunner
204+
.withConfiguration(
205+
AutoConfigurations.of(OpenAiChatAutoConfiguration.class, OpenAiEmbeddingAutoConfiguration.class,
206+
OpenAiImageEditAutoConfiguration.class, OpenAiAudioSpeechAutoConfiguration.class,
207+
OpenAiAudioTranscriptionAutoConfiguration.class, OpenAiModerationAutoConfiguration.class))
208+
.withPropertyValues("spring.ai.model.chat=none", "spring.ai.model.embedding=none",
209+
"spring.ai.model.image=none", "spring.ai.model.image.edit=openai",
210+
"spring.ai.model.audio.speech=none", "spring.ai.model.audio.transcription=none",
211+
"spring.ai.model.moderation=none")
212+
.withConfiguration(AutoConfigurations.of(OpenAiImageAutoConfiguration.class))
213+
.run(context -> {
214+
assertThat(context.getBeansOfType(OpenAiChatModel.class)).isEmpty();
215+
assertThat(context.getBeansOfType(OpenAiEmbeddingModel.class)).isEmpty();
216+
assertThat(context.getBeansOfType(OpenAiImageModel.class)).isEmpty();
217+
assertThat(context.getBeansOfType(OpenAiImageEditModel.class)).isNotEmpty();
218+
assertThat(context.getBeansOfType(OpenAiAudioSpeechModel.class)).isEmpty();
219+
assertThat(context.getBeansOfType(OpenAiAudioTranscriptionModel.class)).isEmpty();
220+
assertThat(context.getBeansOfType(OpenAiModerationModel.class)).isEmpty();
221+
});
222+
}
223+
174224
@Test
175225
void audioSpeechModelActivation() {
176226
this.contextRunner.withConfiguration(AutoConfigurations.of(OpenAiAudioSpeechAutoConfiguration.class))

auto-configurations/models/spring-ai-autoconfigure-model-openai/src/test/java/org/springframework/ai/model/openai/autoconfigure/OpenAiPropertiesTests.java

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,31 @@ public void imageProperties() {
321321
});
322322
}
323323

324+
@Test
325+
public void imageEditProperties() {
326+
new ApplicationContextRunner().withPropertyValues(
327+
// @formatter:off
328+
"spring.ai.openai.base-url=TEST_BASE_URL",
329+
"spring.ai.openai.api-key=abc123",
330+
"spring.ai.openai.image.edit.options.model=MODEL_XYZ",
331+
"spring.ai.openai.image.edit.options.n=3")
332+
// @formatter:on
333+
.withConfiguration(AutoConfigurations.of(OpenAiImageEditAutoConfiguration.class))
334+
.run(context -> {
335+
var imageEditProperties = context.getBean(OpenAiImageEditProperties.class);
336+
var connectionProperties = context.getBean(OpenAiConnectionProperties.class);
337+
338+
assertThat(connectionProperties.getApiKey()).isEqualTo("abc123");
339+
assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL");
340+
341+
assertThat(imageEditProperties.getApiKey()).isNull();
342+
assertThat(imageEditProperties.getBaseUrl()).isNull();
343+
344+
assertThat(imageEditProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ");
345+
assertThat(imageEditProperties.getOptions().getN()).isEqualTo(3);
346+
});
347+
}
348+
324349
@Test
325350
public void imageOverrideConnectionProperties() {
326351
new ApplicationContextRunner().withPropertyValues(

models/spring-ai-azure-openai/src/main/java/org/springframework/ai/azure/openai/AzureOpenAiImageOptions.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,13 @@ public String toString() {
232232

233233
public enum ImageModel {
234234

235+
/**
236+
* GPT Image 1 is our new state-of-the-art image generation model. It is a
237+
* natively multimodal language model that accepts both text and image inputs, and
238+
* produces image outputs.
239+
*/
240+
GPT_IMAGE_1("gpt-image-1"),
241+
235242
/**
236243
* The latest DALL·E model released in Nov 2023.
237244
*/

0 commit comments

Comments
 (0)