Skip to content

Commit c5b45fb

Browse files
committed
feat: add OpenAI gpt-image-1 and gpt-image-1-mini models with new parameters
Add support for OpenAI's new GPT Image models (gpt-image-1 and gpt-image-1-mini) with all new model-specific parameters according to the official API specification. Changes: - Add GPT_IMAGE_1 and GPT_IMAGE_1_MINI to ImageModel enum - Update default image model from DALL_E_3 to GPT_IMAGE_1_MINI - Add 6 new gpt-image-1 specific parameters to OpenAiImageRequest: * background: transparency control (transparent/opaque/auto) * moderation: content moderation level (low/auto) * outputCompression: compression level 0-100% for webp/jpeg * outputFormat: output format (png/jpeg/webp) * partialImages: streaming partial images support (0-3) * stream: enable streaming mode - Update OpenAiImageOptions with new fields, getters/setters, and builder methods - Update documentation to reflect model-specific parameter support - Add comprehensive integration tests (OpenAiImageApiIT) with parameterized tests - Update existing tests to use new default model Breaking Changes: - OpenAiImageRequest constructor signature updated with 6 new parameters Reference: https://platform.openai.com/docs/models Signed-off-by: Alexandros Pappas <apappascs@gmail.com>
1 parent 838801f commit c5b45fb

File tree

13 files changed

+1369
-46
lines changed

13 files changed

+1369
-46
lines changed

auto-configurations/models/spring-ai-autoconfigure-model-openai/src/main/java/org/springframework/ai/model/openai/autoconfigure/OpenAiImageAutoConfiguration.java

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@
2323
import org.springframework.ai.model.SpringAIModelProperties;
2424
import org.springframework.ai.model.SpringAIModels;
2525
import org.springframework.ai.openai.OpenAiImageModel;
26-
import org.springframework.ai.openai.api.OpenAiApi;
2726
import org.springframework.ai.openai.api.OpenAiImageApi;
2827
import org.springframework.ai.retry.autoconfigure.SpringAiRetryAutoConfiguration;
2928
import org.springframework.beans.factory.ObjectProvider;
@@ -38,6 +37,7 @@
3837
import org.springframework.retry.support.RetryTemplate;
3938
import org.springframework.web.client.ResponseErrorHandler;
4039
import org.springframework.web.client.RestClient;
40+
import org.springframework.web.reactive.function.client.WebClient;
4141

4242
import static org.springframework.ai.model.openai.autoconfigure.OpenAIAutoConfigurationUtil.resolveConnectionProperties;
4343

@@ -53,31 +53,38 @@
5353
*/
5454
@AutoConfiguration(after = { RestClientAutoConfiguration.class, WebClientAutoConfiguration.class,
5555
SpringAiRetryAutoConfiguration.class })
56-
@ConditionalOnClass(OpenAiApi.class)
56+
@ConditionalOnClass(OpenAiImageApi.class)
5757
@ConditionalOnProperty(name = SpringAIModelProperties.IMAGE_MODEL, havingValue = SpringAIModels.OPENAI,
5858
matchIfMissing = true)
5959
@EnableConfigurationProperties({ OpenAiConnectionProperties.class, OpenAiImageProperties.class })
6060
public class OpenAiImageAutoConfiguration {
6161

6262
@Bean
6363
@ConditionalOnMissingBean
64-
public OpenAiImageModel openAiImageModel(OpenAiConnectionProperties commonProperties,
64+
public OpenAiImageApi openAiImageApi(OpenAiConnectionProperties commonProperties,
6565
OpenAiImageProperties imageProperties, ObjectProvider<RestClient.Builder> restClientBuilderProvider,
66-
RetryTemplate retryTemplate, ResponseErrorHandler responseErrorHandler,
67-
ObjectProvider<ObservationRegistry> observationRegistry,
68-
ObjectProvider<ImageModelObservationConvention> observationConvention) {
66+
ObjectProvider<WebClient.Builder> webClientBuilderProvider, ResponseErrorHandler responseErrorHandler) {
6967

7068
OpenAIAutoConfigurationUtil.ResolvedConnectionProperties resolved = resolveConnectionProperties(
7169
commonProperties, imageProperties, "image");
7270

73-
var openAiImageApi = OpenAiImageApi.builder()
71+
return OpenAiImageApi.builder()
7472
.baseUrl(resolved.baseUrl())
7573
.apiKey(new SimpleApiKey(resolved.apiKey()))
7674
.headers(resolved.headers())
7775
.imagesPath(imageProperties.getImagesPath())
7876
.restClientBuilder(restClientBuilderProvider.getIfAvailable(RestClient::builder))
77+
.webClientBuilder(webClientBuilderProvider.getIfAvailable(WebClient::builder))
7978
.responseErrorHandler(responseErrorHandler)
8079
.build();
80+
}
81+
82+
@Bean
83+
@ConditionalOnMissingBean
84+
public OpenAiImageModel openAiImageModel(OpenAiImageApi openAiImageApi, OpenAiImageProperties imageProperties,
85+
RetryTemplate retryTemplate, ObjectProvider<ObservationRegistry> observationRegistry,
86+
ObjectProvider<ImageModelObservationConvention> observationConvention) {
87+
8188
var imageModel = new OpenAiImageModel(openAiImageApi, imageProperties.getOptions(), retryTemplate,
8289
observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP));
8390

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
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 org.apache.commons.logging.Log;
20+
import org.apache.commons.logging.LogFactory;
21+
import org.junit.jupiter.api.Test;
22+
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
23+
24+
import org.springframework.ai.image.ImagePrompt;
25+
import org.springframework.ai.image.ImageResponse;
26+
import org.springframework.ai.model.tool.autoconfigure.ToolCallingAutoConfiguration;
27+
import org.springframework.ai.openai.OpenAiImageModel;
28+
import org.springframework.ai.openai.api.OpenAiImageApi;
29+
import org.springframework.ai.retry.autoconfigure.SpringAiRetryAutoConfiguration;
30+
import org.springframework.boot.autoconfigure.AutoConfigurations;
31+
import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration;
32+
import org.springframework.boot.autoconfigure.web.reactive.function.client.WebClientAutoConfiguration;
33+
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
34+
35+
import static org.assertj.core.api.Assertions.assertThat;
36+
37+
/**
38+
* Integration tests for {@link OpenAiImageAutoConfiguration}.
39+
*
40+
* @author Alexandros Pappas
41+
* @since 1.1.0
42+
*/
43+
@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+")
44+
public class OpenAiImageAutoConfigurationIT {
45+
46+
private static final Log logger = LogFactory.getLog(OpenAiImageAutoConfigurationIT.class);
47+
48+
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
49+
.withPropertyValues("spring.ai.openai.apiKey=" + System.getenv("OPENAI_API_KEY"))
50+
.withConfiguration(
51+
AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, RestClientAutoConfiguration.class,
52+
WebClientAutoConfiguration.class, ToolCallingAutoConfiguration.class));
53+
54+
@Test
55+
void imageModelAutoConfigured() {
56+
this.contextRunner.withConfiguration(AutoConfigurations.of(OpenAiImageAutoConfiguration.class)).run(context -> {
57+
assertThat(context.getBeansOfType(OpenAiImageModel.class)).isNotEmpty();
58+
assertThat(context.getBeansOfType(OpenAiImageApi.class)).isNotEmpty();
59+
});
60+
}
61+
62+
@Test
63+
void generateImage() {
64+
this.contextRunner
65+
.withPropertyValues("spring.ai.openai.image.options.model=dall-e-2",
66+
"spring.ai.openai.image.options.response-format=b64_json")
67+
.withConfiguration(AutoConfigurations.of(OpenAiImageAutoConfiguration.class))
68+
.run(context -> {
69+
OpenAiImageModel imageModel = context.getBean(OpenAiImageModel.class);
70+
ImagePrompt prompt = new ImagePrompt("A simple red circle");
71+
ImageResponse response = imageModel.call(prompt);
72+
73+
assertThat(response).isNotNull();
74+
assertThat(response.getResults()).hasSize(1);
75+
assertThat(response.getResult().getOutput().getB64Json()).isNotEmpty();
76+
77+
logger.info("Generated image with base64 length: "
78+
+ response.getResult().getOutput().getB64Json().length());
79+
});
80+
}
81+
82+
@Test
83+
void imageModelDisabled() {
84+
this.contextRunner.withPropertyValues("spring.ai.model.image=none")
85+
.withConfiguration(AutoConfigurations.of(OpenAiImageAutoConfiguration.class))
86+
.run(context -> assertThat(context.getBeansOfType(OpenAiImageModel.class)).isEmpty());
87+
}
88+
89+
@Test
90+
void imageModelExplicitlyEnabled() {
91+
this.contextRunner.withPropertyValues("spring.ai.model.image=openai")
92+
.withConfiguration(AutoConfigurations.of(OpenAiImageAutoConfiguration.class))
93+
.run(context -> assertThat(context.getBeansOfType(OpenAiImageModel.class)).isNotEmpty());
94+
}
95+
96+
}

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

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -539,6 +539,46 @@ public void imageOptionsTest() {
539539
});
540540
}
541541

542+
@Test
543+
public void imageGptImageOptionsTest() {
544+
this.contextRunner.withPropertyValues(
545+
// @formatter:off
546+
"spring.ai.openai.api-key=API_KEY",
547+
"spring.ai.openai.base-url=TEST_BASE_URL",
548+
549+
"spring.ai.openai.image.options.model=gpt-image-1",
550+
"spring.ai.openai.image.options.quality=high",
551+
"spring.ai.openai.image.options.size=1024x1024",
552+
"spring.ai.openai.image.options.background=transparent",
553+
"spring.ai.openai.image.options.moderation=low",
554+
"spring.ai.openai.image.options.output_compression=85",
555+
"spring.ai.openai.image.options.output_format=png",
556+
"spring.ai.openai.image.options.partial_images=2",
557+
"spring.ai.openai.image.options.stream=true",
558+
"spring.ai.openai.image.options.user=userXYZ"
559+
)
560+
// @formatter:on
561+
.withConfiguration(AutoConfigurations.of(OpenAiImageAutoConfiguration.class))
562+
.run(context -> {
563+
var imageProperties = context.getBean(OpenAiImageProperties.class);
564+
var connectionProperties = context.getBean(OpenAiConnectionProperties.class);
565+
566+
assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL");
567+
assertThat(connectionProperties.getApiKey()).isEqualTo("API_KEY");
568+
569+
assertThat(imageProperties.getOptions().getModel()).isEqualTo("gpt-image-1");
570+
assertThat(imageProperties.getOptions().getQuality()).isEqualTo("high");
571+
assertThat(imageProperties.getOptions().getSize()).isEqualTo("1024x1024");
572+
assertThat(imageProperties.getOptions().getBackground()).isEqualTo("transparent");
573+
assertThat(imageProperties.getOptions().getModeration()).isEqualTo("low");
574+
assertThat(imageProperties.getOptions().getOutputCompression()).isEqualTo(85);
575+
assertThat(imageProperties.getOptions().getOutputFormat()).isEqualTo("png");
576+
assertThat(imageProperties.getOptions().getPartialImages()).isEqualTo(2);
577+
assertThat(imageProperties.getOptions().getStream()).isTrue();
578+
assertThat(imageProperties.getOptions().getUser()).isEqualTo("userXYZ");
579+
});
580+
}
581+
542582
@Test
543583
void embeddingActivation() {
544584

models/spring-ai-openai/pom.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,11 @@
117117
<scope>test</scope>
118118
</dependency>
119119

120+
<dependency>
121+
<groupId>io.projectreactor</groupId>
122+
<artifactId>reactor-test</artifactId>
123+
<scope>test</scope>
124+
</dependency>
120125
</dependencies>
121126

122127
</project>

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

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import io.micrometer.observation.ObservationRegistry;
2222
import org.slf4j.Logger;
2323
import org.slf4j.LoggerFactory;
24+
import reactor.core.publisher.Flux;
2425

2526
import org.springframework.ai.image.Image;
2627
import org.springframework.ai.image.ImageGeneration;
@@ -29,6 +30,7 @@
2930
import org.springframework.ai.image.ImagePrompt;
3031
import org.springframework.ai.image.ImageResponse;
3132
import org.springframework.ai.image.ImageResponseMetadata;
33+
import org.springframework.ai.image.StreamingImageModel;
3234
import org.springframework.ai.image.observation.DefaultImageModelObservationConvention;
3335
import org.springframework.ai.image.observation.ImageModelObservationContext;
3436
import org.springframework.ai.image.observation.ImageModelObservationConvention;
@@ -43,16 +45,26 @@
4345
import org.springframework.util.Assert;
4446

4547
/**
46-
* OpenAiImageModel is a class that implements the ImageModel interface. It provides a
47-
* client for calling the OpenAI image generation API.
48+
* OpenAiImageModel is a class that implements the ImageModel and StreamingImageModel
49+
* interfaces. It provides a client for calling the OpenAI image generation API with both
50+
* synchronous and streaming capabilities.
51+
*
52+
* <p>
53+
* Streaming image generation is supported for GPT-Image models (gpt-image-1,
54+
* gpt-image-1-mini) and allows receiving partial images as they are generated. DALL-E
55+
* models do not support streaming.
56+
* </p>
4857
*
4958
* @author Mark Pollack
5059
* @author Christian Tzolov
5160
* @author Hyunjoon Choi
5261
* @author Thomas Vitale
62+
* @author Alexandros Pappas
5363
* @since 0.8.0
64+
* @see ImageModel
65+
* @see StreamingImageModel
5466
*/
55-
public class OpenAiImageModel implements ImageModel {
67+
public class OpenAiImageModel implements ImageModel, StreamingImageModel {
5668

5769
private static final Logger logger = LoggerFactory.getLogger(OpenAiImageModel.class);
5870

@@ -205,6 +217,51 @@ private ImagePrompt buildRequestImagePrompt(ImagePrompt imagePrompt) {
205217
return new ImagePrompt(imagePrompt.getInstructions(), requestOptions);
206218
}
207219

220+
@Override
221+
public Flux<ImageResponse> stream(ImagePrompt imagePrompt) {
222+
// Before moving any further, build the final request ImagePrompt,
223+
// merging runtime and default options.
224+
ImagePrompt requestImagePrompt = buildRequestImagePrompt(imagePrompt);
225+
226+
OpenAiImageApi.OpenAiImageRequest imageRequest = createRequest(requestImagePrompt);
227+
228+
// Validate that streaming is only used with GPT-Image models
229+
String model = imageRequest.model();
230+
if (model != null && !model.startsWith("gpt-image-")) {
231+
return Flux.error(new IllegalArgumentException(
232+
"Streaming is only supported for GPT-Image models (gpt-image-1, gpt-image-1-mini). "
233+
+ "Current model: " + model));
234+
}
235+
236+
// Ensure stream is set to true
237+
if (imageRequest.stream() == null || !imageRequest.stream()) {
238+
imageRequest = new OpenAiImageApi.OpenAiImageRequest(imageRequest.prompt(), imageRequest.model(),
239+
imageRequest.n(), imageRequest.quality(), imageRequest.responseFormat(), imageRequest.size(),
240+
imageRequest.style(), imageRequest.user(), imageRequest.background(), imageRequest.moderation(),
241+
imageRequest.outputCompression(), imageRequest.outputFormat(), imageRequest.partialImages(), true);
242+
}
243+
244+
var observationContext = ImageModelObservationContext.builder()
245+
.imagePrompt(imagePrompt)
246+
.provider(OpenAiApiConstants.PROVIDER_NAME)
247+
.build();
248+
249+
OpenAiImageApi.OpenAiImageRequest finalImageRequest = imageRequest;
250+
251+
// Stream the image generation events
252+
Flux<OpenAiImageApi.OpenAiImageStreamEvent> eventStream = this.openAiImageApi.streamImage(finalImageRequest);
253+
254+
// Convert streaming events to ImageResponse
255+
return eventStream.map(event -> {
256+
Image image = new Image(null, event.b64Json());
257+
OpenAiImageGenerationMetadata metadata = new OpenAiImageGenerationMetadata(null);
258+
ImageGeneration generation = new ImageGeneration(image, metadata);
259+
ImageResponseMetadata responseMetadata = event.createdAt() != null
260+
? new ImageResponseMetadata(event.createdAt()) : new ImageResponseMetadata(null);
261+
return new ImageResponse(List.of(generation), responseMetadata);
262+
});
263+
}
264+
208265
/**
209266
* Use the provided convention for reporting observation data
210267
* @param observationConvention The provided convention

0 commit comments

Comments
 (0)