diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 000000000..d8b0cbdc2 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,12 @@ +{ + "permissions": { + "allow": [ + "Bash(gh pr list:*)", + "Bash(gh pr view:*)", + "Bash(gh issue list:*)", + "Bash(gh issue view:*)", + "WebFetch(domain:github.com)" + ], + "deny": [] + } +} \ No newline at end of file diff --git a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiImageOptions.java b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiImageOptions.java index bb9170a63..3b294a5b0 100644 --- a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiImageOptions.java +++ b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiImageOptions.java @@ -112,6 +112,25 @@ public class OpenAiImageOptions implements ImageOptions { return new Builder(); } + /** + * Create a new OpenAiImageOptions instance from an existing one. + * @param fromOptions The options to copy from + * @return A new OpenAiImageOptions instance + */ + public static OpenAiImageOptions fromOptions(OpenAiImageOptions fromOptions) { + OpenAiImageOptions options = new OpenAiImageOptions(); + options.n = fromOptions.n; + options.model = fromOptions.model; + options.width = fromOptions.width; + options.height = fromOptions.height; + options.quality = fromOptions.quality; + options.responseFormat = fromOptions.responseFormat; + options.size = fromOptions.size; + options.style = fromOptions.style; + options.user = fromOptions.user; + return options; + } + @Override public Integer getN() { return this.n; @@ -227,6 +246,20 @@ public class OpenAiImageOptions implements ImageOptions { public void setSize(String size) { this.size = size; + + // Parse the size string to update width and height + if (size != null) { + try { + String[] dimensions = size.split("x"); + if (dimensions.length == 2) { + this.width = Integer.parseInt(dimensions[0]); + this.height = Integer.parseInt(dimensions[1]); + } + } + catch (Exception ex) { + // If parsing fails, leave width and height unchanged + } + } } @Override @@ -258,14 +291,26 @@ public class OpenAiImageOptions implements ImageOptions { + ", user='" + this.user + '\'' + '}'; } + /** + * Create a copy of this options instance. + * @return A new instance with the same options + */ + public OpenAiImageOptions copy() { + return fromOptions(this); + } + public static final class Builder { - private final OpenAiImageOptions options; + protected OpenAiImageOptions options; - private Builder() { + public Builder() { this.options = new OpenAiImageOptions(); } + public Builder(OpenAiImageOptions options) { + this.options = options; + } + public Builder N(Integer n) { this.options.setN(n); return this; @@ -306,46 +351,6 @@ public class OpenAiImageOptions implements ImageOptions { return this; } - public Builder withN(Integer n) { - this.options.setN(n); - return this; - } - - public Builder withModel(String model) { - this.options.setModel(model); - return this; - } - - public Builder withQuality(String quality) { - this.options.setQuality(quality); - return this; - } - - public Builder withResponseFormat(String responseFormat) { - this.options.setResponseFormat(responseFormat); - return this; - } - - public Builder withWidth(Integer width) { - this.options.setWidth(width); - return this; - } - - public Builder withHeight(Integer height) { - this.options.setHeight(height); - return this; - } - - public Builder withStyle(String style) { - this.options.setStyle(style); - return this; - } - - public Builder withUser(String user) { - this.options.setUser(user); - return this; - } - public OpenAiImageOptions build() { return this.options; } diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/OpenAiImageOptionsTests.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/OpenAiImageOptionsTests.java index 86ebbcc21..faa266ebb 100644 --- a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/OpenAiImageOptionsTests.java +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/OpenAiImageOptionsTests.java @@ -24,9 +24,213 @@ import static org.assertj.core.api.Assertions.assertThat; * Unit tests for {@link OpenAiImageOptions}. * * @author Thomas Vitale + * @author Mark Pollack */ class OpenAiImageOptionsTests { + @Test + void testBuilderWithAllFields() { + OpenAiImageOptions options = OpenAiImageOptions.builder() + .N(2) + .model("dall-e-3") + .quality("hd") + .responseFormat("url") + .width(1024) + .height(1024) + .style("vivid") + .user("test-user") + .build(); + + assertThat(options.getN()).isEqualTo(2); + assertThat(options.getModel()).isEqualTo("dall-e-3"); + assertThat(options.getQuality()).isEqualTo("hd"); + assertThat(options.getResponseFormat()).isEqualTo("url"); + assertThat(options.getWidth()).isEqualTo(1024); + assertThat(options.getHeight()).isEqualTo(1024); + assertThat(options.getSize()).isEqualTo("1024x1024"); + assertThat(options.getStyle()).isEqualTo("vivid"); + assertThat(options.getUser()).isEqualTo("test-user"); + } + + @Test + void testCopy() { + OpenAiImageOptions original = OpenAiImageOptions.builder() + .N(3) + .model("dall-e-3") + .quality("standard") + .responseFormat("b64_json") + .width(1792) + .height(1024) + .style("natural") + .user("original-user") + .build(); + + // Test fromOptions static method + OpenAiImageOptions copied = OpenAiImageOptions.fromOptions(original); + assertThat(copied).isNotSameAs(original); + assertThat(copied.getN()).isEqualTo(original.getN()); + assertThat(copied.getModel()).isEqualTo(original.getModel()); + assertThat(copied.getQuality()).isEqualTo(original.getQuality()); + assertThat(copied.getResponseFormat()).isEqualTo(original.getResponseFormat()); + assertThat(copied.getWidth()).isEqualTo(original.getWidth()); + assertThat(copied.getHeight()).isEqualTo(original.getHeight()); + assertThat(copied.getSize()).isEqualTo(original.getSize()); + assertThat(copied.getStyle()).isEqualTo(original.getStyle()); + assertThat(copied.getUser()).isEqualTo(original.getUser()); + + // Test copy instance method + OpenAiImageOptions copiedViaMethod = original.copy(); + assertThat(copiedViaMethod).isNotSameAs(original); + assertThat(copiedViaMethod.getN()).isEqualTo(original.getN()); + assertThat(copiedViaMethod.getModel()).isEqualTo(original.getModel()); + assertThat(copiedViaMethod.getQuality()).isEqualTo(original.getQuality()); + assertThat(copiedViaMethod.getResponseFormat()).isEqualTo(original.getResponseFormat()); + assertThat(copiedViaMethod.getWidth()).isEqualTo(original.getWidth()); + assertThat(copiedViaMethod.getHeight()).isEqualTo(original.getHeight()); + assertThat(copiedViaMethod.getSize()).isEqualTo(original.getSize()); + assertThat(copiedViaMethod.getStyle()).isEqualTo(original.getStyle()); + assertThat(copiedViaMethod.getUser()).isEqualTo(original.getUser()); + } + + @Test + void testSetters() { + OpenAiImageOptions options = new OpenAiImageOptions(); + + options.setN(4); + options.setModel("dall-e-2"); + options.setQuality("standard"); + options.setResponseFormat("url"); + options.setWidth(512); + options.setHeight(512); + options.setStyle("vivid"); + options.setUser("test-setter-user"); + + assertThat(options.getN()).isEqualTo(4); + assertThat(options.getModel()).isEqualTo("dall-e-2"); + assertThat(options.getQuality()).isEqualTo("standard"); + assertThat(options.getResponseFormat()).isEqualTo("url"); + assertThat(options.getWidth()).isEqualTo(512); + assertThat(options.getHeight()).isEqualTo(512); + assertThat(options.getSize()).isEqualTo("512x512"); + assertThat(options.getStyle()).isEqualTo("vivid"); + assertThat(options.getUser()).isEqualTo("test-setter-user"); + + // Test size setter + options.setSize("256x256"); + assertThat(options.getSize()).isEqualTo("256x256"); + assertThat(options.getWidth()).isEqualTo(256); + assertThat(options.getHeight()).isEqualTo(256); + } + + @Test + void testDefaultValues() { + OpenAiImageOptions options = new OpenAiImageOptions(); + + assertThat(options.getN()).isNull(); + assertThat(options.getModel()).isNull(); + assertThat(options.getQuality()).isNull(); + assertThat(options.getResponseFormat()).isNull(); + assertThat(options.getWidth()).isNull(); + assertThat(options.getHeight()).isNull(); + assertThat(options.getSize()).isNull(); + assertThat(options.getStyle()).isNull(); + assertThat(options.getUser()).isNull(); + } + + @Test + void testBuilderWithExistingOptions() { + OpenAiImageOptions original = OpenAiImageOptions.builder().N(1).model("dall-e-3").quality("hd").build(); + + OpenAiImageOptions modified = new OpenAiImageOptions.Builder(original).width(1024).height(1024).build(); + + assertThat(modified.getN()).isEqualTo(1); + assertThat(modified.getModel()).isEqualTo("dall-e-3"); + assertThat(modified.getQuality()).isEqualTo("hd"); + assertThat(modified.getWidth()).isEqualTo(1024); + assertThat(modified.getHeight()).isEqualTo(1024); + assertThat(modified.getSize()).isEqualTo("1024x1024"); + } + + @Test + void testEqualsAndHashCode() { + OpenAiImageOptions options1 = OpenAiImageOptions.builder() + .N(2) + .model("dall-e-3") + .quality("hd") + .width(1024) + .height(1024) + .build(); + + OpenAiImageOptions options2 = OpenAiImageOptions.builder() + .N(2) + .model("dall-e-3") + .quality("hd") + .width(1024) + .height(1024) + .build(); + + OpenAiImageOptions options3 = OpenAiImageOptions.builder() + .N(3) // Different value + .model("dall-e-3") + .quality("hd") + .width(1024) + .height(1024) + .build(); + + // Test equals + assertThat(options1).isEqualTo(options1); // Same instance + assertThat(options1).isEqualTo(options2); // Equal objects + assertThat(options1).isNotEqualTo(options3); // Different objects + assertThat(options1).isNotEqualTo(null); // Null + assertThat(options1).isNotEqualTo("not an options object"); // Different type + + // Test hashCode + assertThat(options1.hashCode()).isEqualTo(options2.hashCode()); // Equal objects + // have equal hash + // codes + assertThat(options1.hashCode()).isNotEqualTo(options3.hashCode()); // Different + // objects + // have + // different + // hash codes + } + + @Test + void testToString() { + OpenAiImageOptions options = OpenAiImageOptions.builder().N(2).model("dall-e-3").build(); + + String toString = options.toString(); + assertThat(toString).contains("n=2"); + assertThat(toString).contains("model='dall-e-3'"); + assertThat(toString).contains("OpenAiImageOptions"); + } + + @Test + void testFluentApiPattern() { + // Test the fluent API pattern by chaining method calls + OpenAiImageOptions options = OpenAiImageOptions.builder() + .N(1) + .model("dall-e-3") + .quality("hd") + .responseFormat("url") + .width(1024) + .height(1024) + .style("vivid") + .user("test-user") + .build(); + + assertThat(options.getN()).isEqualTo(1); + assertThat(options.getModel()).isEqualTo("dall-e-3"); + assertThat(options.getQuality()).isEqualTo("hd"); + assertThat(options.getResponseFormat()).isEqualTo("url"); + assertThat(options.getWidth()).isEqualTo(1024); + assertThat(options.getHeight()).isEqualTo(1024); + assertThat(options.getSize()).isEqualTo("1024x1024"); + assertThat(options.getStyle()).isEqualTo("vivid"); + assertThat(options.getUser()).isEqualTo("test-user"); + } + + // Original size-related tests @Test void whenImageDimensionsAreAllUnset() { OpenAiImageOptions options = new OpenAiImageOptions(); @@ -110,4 +314,57 @@ class OpenAiImageOptionsTests { assertThat(options.getSize()).isEqualTo("1024x1024x1024"); } + @Test + void testSetSizeUpdatesWidthAndHeight() { + OpenAiImageOptions options = new OpenAiImageOptions(); + + // Test setting size updates width and height + options.setSize("800x600"); + assertThat(options.getSize()).isEqualTo("800x600"); + assertThat(options.getWidth()).isEqualTo(800); + assertThat(options.getHeight()).isEqualTo(600); + + // Test changing size updates width and height + options.setSize("1920x1080"); + assertThat(options.getSize()).isEqualTo("1920x1080"); + assertThat(options.getWidth()).isEqualTo(1920); + assertThat(options.getHeight()).isEqualTo(1080); + } + + @Test + void testSetSizeWithInvalidFormatPreservesExistingWidthAndHeight() { + OpenAiImageOptions options = new OpenAiImageOptions(); + + // First set valid width and height + options.setWidth(1024); + options.setHeight(768); + + // Now set invalid size format + options.setSize("invalid"); + + // Size should be updated, but width and height should remain unchanged + assertThat(options.getSize()).isEqualTo("invalid"); + assertThat(options.getWidth()).isEqualTo(1024); // Should preserve existing value + assertThat(options.getHeight()).isEqualTo(768); // Should preserve existing value + } + + @Test + void testSetSizeWithNullClearsSize() { + OpenAiImageOptions options = new OpenAiImageOptions(); + + // First set valid values + options.setSize("800x600"); + assertThat(options.getWidth()).isEqualTo(800); + assertThat(options.getHeight()).isEqualTo(600); + + // Now set size to null + options.setSize(null); + + // The size field is null, but getSize() computes it from width and height + // This is the expected behavior based on the implementation of getSize() + assertThat(options.getSize()).isEqualTo("800x600"); + assertThat(options.getWidth()).isEqualTo(800); // Should preserve existing value + assertThat(options.getHeight()).isEqualTo(600); // Should preserve existing value + } + } diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/upgrade-notes.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/upgrade-notes.adoc index 78649cc23..bc5ffc5e0 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/upgrade-notes.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/upgrade-notes.adoc @@ -35,6 +35,12 @@ Hopefully Watson will reappear in a future version of Spring AI + +[[upgrading-to-1-0-0-RC1]] +== Upgrading to 1.0.0-RC1 + + + [[upgrading-to-1-0-0-m8]] == Upgrading to 1.0.0-M8