From b2418984f832b6107cdd2272edd661982e79b833 Mon Sep 17 00:00:00 2001 From: Kayvan Sylvan Date: Fri, 4 Jul 2025 23:04:50 -0700 Subject: [PATCH] feat: add advanced image generation parameters for OpenAI models ## CHANGES - Add four new image generation CLI flags - Implement validation for image parameter combinations - Support size, quality, compression, and background controls - Add comprehensive test coverage for new parameters - Update shell completions for new image options - Enhance README with detailed image generation examples - Fix PowerShell code block formatting issues --- README.md | 167 +++++++++++------------ cli/flags.go | 91 +++++++++++++ cli/flags_test.go | 178 +++++++++++++++++++++++++ common/domain.go | 4 + completions/_fabric | 4 + completions/fabric.bash | 17 ++- completions/fabric.fish | 4 + plugins/ai/openai/openai_image.go | 26 +++- plugins/ai/openai/openai_image_test.go | 104 +++++++++++++++ 9 files changed, 509 insertions(+), 86 deletions(-) diff --git a/README.md b/README.md index cd46e974..3491edfe 100644 --- a/README.md +++ b/README.md @@ -114,13 +114,11 @@ Keep in mind that many of these were recorded when Fabric was Python-based, so r > > July 4, 2025 > -> - Fabric now supports web search using the `--search` and `--search-location` flags -> - Web search is available for both Anthropic and OpenAI providers -> - Previous plugin-level search configurations have been removed in favor of the new flag-based approach. -> - If you used the previous approach, consider cleaning up your `~/.config/fabric/.env` file, removing the unused `ANTHROPIC_WEB_SEARCH_TOOL_ENABLED` and `ANTHROPIC_WEB_SEARCH_TOOL_LOCATION` variables. -> - Fabric now supports image generation using the `--image-file` flag with OpenAI models -> - Image generation works with both text prompts and input images (via `--attachment`) for image editing tasks -> +> - **Web Search**: Fabric now supports web search for Anthropic and OpenAI models using the `--search` and `--search-location` flags. This replaces the previous plugin-based search, so you may want to remove the old `ANTHROPIC_WEB_SEARCH_TOOL_*` variables from your `~/.config/fabric/.env` file. +> - **Image Generation**: Fabric now has powerful image generation capabilities with OpenAI. +> - Generate images from text prompts and save them using `--image-file`. +> - Edit existing images by providing an input image with `--attachment`. +> - Control image `size`, `quality`, `compression`, and `background` with the new `--image-*` flags. > >June 17, 2025 > @@ -292,88 +290,88 @@ yt() { You can add the below code for the equivalent aliases inside PowerShell by running `notepad $PROFILE` inside a PowerShell window: - ```powershell - # Path to the patterns directory - $patternsPath = Join-Path $HOME ".config/fabric/patterns" - foreach ($patternDir in Get-ChildItem -Path $patternsPath -Directory) { - $patternName = $patternDir.Name +```powershell +# Path to the patterns directory +$patternsPath = Join-Path $HOME ".config/fabric/patterns" +foreach ($patternDir in Get-ChildItem -Path $patternsPath -Directory) { + $patternName = $patternDir.Name - # Dynamically define a function for each pattern - $functionDefinition = @" - function $patternName { - [CmdletBinding()] - param( - [Parameter(ValueFromPipeline = `$true)] - [string] `$InputObject, + # Dynamically define a function for each pattern + $functionDefinition = @" +function $patternName { + [CmdletBinding()] + param( + [Parameter(ValueFromPipeline = `$true)] + [string] `$InputObject, - [Parameter(ValueFromRemainingArguments = `$true)] - [String[]] `$patternArgs - ) + [Parameter(ValueFromRemainingArguments = `$true)] + [String[]] `$patternArgs + ) - begin { - # Initialize an array to collect pipeline input - `$collector = @() - } - - process { - # Collect pipeline input objects - if (`$InputObject) { - `$collector += `$InputObject - } - } - - end { - # Join all pipeline input into a single string, separated by newlines - `$pipelineContent = `$collector -join "`n" - - # If there's pipeline input, include it in the call to fabric - if (`$pipelineContent) { - `$pipelineContent | fabric --pattern $patternName `$patternArgs - } else { - # No pipeline input; just call fabric with the additional args - fabric --pattern $patternName `$patternArgs - } - } - } - "@ - # Add the function to the current session - Invoke-Expression $functionDefinition + begin { + # Initialize an array to collect pipeline input + `$collector = @() } - # Define the 'yt' function as well - function yt { - [CmdletBinding()] - param( - [Parameter()] - [Alias("timestamps")] - [switch]$t, - - [Parameter(Position = 0, ValueFromPipeline = $true)] - [string]$videoLink - ) - - begin { - $transcriptFlag = "--transcript" - if ($t) { - $transcriptFlag = "--transcript-with-timestamps" - } - } - - process { - if (-not $videoLink) { - Write-Error "Usage: yt [-t | --timestamps] youtube-link" - return - } - } - - end { - if ($videoLink) { - # Execute and allow output to flow through the pipeline - fabric -y $videoLink $transcriptFlag - } + process { + # Collect pipeline input objects + if (`$InputObject) { + `$collector += `$InputObject } } - ``` + + end { + # Join all pipeline input into a single string, separated by newlines + `$pipelineContent = `$collector -join "`n" + + # If there's pipeline input, include it in the call to fabric + if (`$pipelineContent) { + `$pipelineContent | fabric --pattern $patternName `$patternArgs + } else { + # No pipeline input; just call fabric with the additional args + fabric --pattern $patternName `$patternArgs + } + } +} +"@ + # Add the function to the current session + Invoke-Expression $functionDefinition +} + +# Define the 'yt' function as well +function yt { + [CmdletBinding()] + param( + [Parameter()] + [Alias("timestamps")] + [switch]$t, + + [Parameter(Position = 0, ValueFromPipeline = $true)] + [string]$videoLink + ) + + begin { + $transcriptFlag = "--transcript" + if ($t) { + $transcriptFlag = "--transcript-with-timestamps" + } + } + + process { + if (-not $videoLink) { + Write-Error "Usage: yt [-t | --timestamps] youtube-link" + return + } + } + + end { + if ($videoLink) { + # Execute and allow output to flow through the pipeline + fabric -y $videoLink $transcriptFlag + } + } +} +``` This also creates a `yt` alias that allows you to use `yt https://www.youtube.com/watch?v=4b0iet22VIk` to get transcripts, comments, and metadata. @@ -559,6 +557,11 @@ Application Options: --search Enable web search tool for supported models (Anthropic, OpenAI) --search-location= Set location for web search results (e.g., 'America/Los_Angeles') --image-file= Save generated image to specified file path (e.g., 'output.png') + --image-size= Image dimensions: 1024x1024, 1536x1024, 1024x1536, auto (default: auto) + --image-quality= Image quality: low, medium, high, auto (default: auto) + --image-compression= Compression level 0-100 for JPEG/WebP formats (default: not set) + --image-background= Background type: opaque, transparent (default: opaque, only for + PNG/WebP) Help Options: -h, --help Show this help message diff --git a/cli/flags.go b/cli/flags.go index 0bbf33ab..276c1f7f 100644 --- a/cli/flags.go +++ b/cli/flags.go @@ -78,6 +78,10 @@ type Flags struct { Search bool `long:"search" description:"Enable web search tool for supported models (Anthropic, OpenAI)"` SearchLocation string `long:"search-location" description:"Set location for web search results (e.g., 'America/Los_Angeles')"` ImageFile string `long:"image-file" description:"Save generated image to specified file path (e.g., 'output.png')"` + ImageSize string `long:"image-size" description:"Image dimensions: 1024x1024, 1536x1024, 1024x1536, auto (default: auto)"` + ImageQuality string `long:"image-quality" description:"Image quality: low, medium, high, auto (default: auto)"` + ImageCompression int `long:"image-compression" description:"Compression level 0-100 for JPEG/WebP formats (default: not set)"` + ImageBackground string `long:"image-background" description:"Background type: opaque, transparent (default: opaque, only for PNG/WebP)"` } var debug = false @@ -282,12 +286,95 @@ func validateImageFile(imagePath string) error { return fmt.Errorf("invalid image file extension '%s'. Supported formats: .png, .jpeg, .jpg, .webp", ext) } +// validateImageParameters validates image generation parameters +func validateImageParameters(imagePath, size, quality, background string, compression int) error { + if imagePath == "" { + // Check if any image parameters are specified without --image-file + if size != "" || quality != "" || background != "" || compression != 0 { + return fmt.Errorf("image parameters (--image-size, --image-quality, --image-background, --image-compression) can only be used with --image-file") + } + return nil + } + + // Validate size + if size != "" { + validSizes := []string{"1024x1024", "1536x1024", "1024x1536", "auto"} + valid := false + for _, validSize := range validSizes { + if size == validSize { + valid = true + break + } + } + if !valid { + return fmt.Errorf("invalid image size '%s'. Supported sizes: 1024x1024, 1536x1024, 1024x1536, auto", size) + } + } + + // Validate quality + if quality != "" { + validQualities := []string{"low", "medium", "high", "auto"} + valid := false + for _, validQuality := range validQualities { + if quality == validQuality { + valid = true + break + } + } + if !valid { + return fmt.Errorf("invalid image quality '%s'. Supported qualities: low, medium, high, auto", quality) + } + } + + // Validate background + if background != "" { + validBackgrounds := []string{"opaque", "transparent"} + valid := false + for _, validBackground := range validBackgrounds { + if background == validBackground { + valid = true + break + } + } + if !valid { + return fmt.Errorf("invalid image background '%s'. Supported backgrounds: opaque, transparent", background) + } + } + + // Get file format for format-specific validations + ext := strings.ToLower(filepath.Ext(imagePath)) + + // Validate compression (only for jpeg/webp) + if compression != 0 { // 0 means not set + if ext != ".jpg" && ext != ".jpeg" && ext != ".webp" { + return fmt.Errorf("image compression can only be used with JPEG and WebP formats, not %s", ext) + } + if compression < 0 || compression > 100 { + return fmt.Errorf("image compression must be between 0 and 100, got %d", compression) + } + } + + // Validate background transparency (only for png/webp) + if background == "transparent" { + if ext != ".png" && ext != ".webp" { + return fmt.Errorf("transparent background can only be used with PNG and WebP formats, not %s", ext) + } + } + + return nil +} + func (o *Flags) BuildChatOptions() (ret *common.ChatOptions, err error) { // Validate image file if specified if err = validateImageFile(o.ImageFile); err != nil { return nil, err } + // Validate image parameters + if err = validateImageParameters(o.ImageFile, o.ImageSize, o.ImageQuality, o.ImageBackground, o.ImageCompression); err != nil { + return nil, err + } + ret = &common.ChatOptions{ Model: o.Model, Temperature: o.Temperature, @@ -300,6 +387,10 @@ func (o *Flags) BuildChatOptions() (ret *common.ChatOptions, err error) { Search: o.Search, SearchLocation: o.SearchLocation, ImageFile: o.ImageFile, + ImageSize: o.ImageSize, + ImageQuality: o.ImageQuality, + ImageCompression: o.ImageCompression, + ImageBackground: o.ImageBackground, } return } diff --git a/cli/flags_test.go b/cli/flags_test.go index 0f461c6a..84e8baad 100644 --- a/cli/flags_test.go +++ b/cli/flags_test.go @@ -255,3 +255,181 @@ func TestBuildChatOptionsWithImageFileValidation(t *testing.T) { assert.Contains(t, err.Error(), "image file already exists") }) } + +func TestValidateImageParameters(t *testing.T) { + t.Run("No image file and no parameters should pass", func(t *testing.T) { + err := validateImageParameters("", "", "", "", 0) + assert.NoError(t, err) + }) + + t.Run("Image parameters without image file should fail", func(t *testing.T) { + // Test each parameter individually + err := validateImageParameters("", "1024x1024", "", "", 0) + assert.Error(t, err) + assert.Contains(t, err.Error(), "image parameters") + assert.Contains(t, err.Error(), "can only be used with --image-file") + + err = validateImageParameters("", "", "high", "", 0) + assert.Error(t, err) + assert.Contains(t, err.Error(), "image parameters") + + err = validateImageParameters("", "", "", "transparent", 0) + assert.Error(t, err) + assert.Contains(t, err.Error(), "image parameters") + + err = validateImageParameters("", "", "", "", 50) + assert.Error(t, err) + assert.Contains(t, err.Error(), "image parameters") + + // Test multiple parameters + err = validateImageParameters("", "1024x1024", "high", "transparent", 50) + assert.Error(t, err) + assert.Contains(t, err.Error(), "image parameters") + }) + + t.Run("Valid size values should pass", func(t *testing.T) { + validSizes := []string{"1024x1024", "1536x1024", "1024x1536", "auto"} + for _, size := range validSizes { + err := validateImageParameters("/tmp/test.png", size, "", "", 0) + assert.NoError(t, err, "Size %s should be valid", size) + } + }) + + t.Run("Invalid size should fail", func(t *testing.T) { + err := validateImageParameters("/tmp/test.png", "invalid", "", "", 0) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid image size") + }) + + t.Run("Valid quality values should pass", func(t *testing.T) { + validQualities := []string{"low", "medium", "high", "auto"} + for _, quality := range validQualities { + err := validateImageParameters("/tmp/test.png", "", quality, "", 0) + assert.NoError(t, err, "Quality %s should be valid", quality) + } + }) + + t.Run("Invalid quality should fail", func(t *testing.T) { + err := validateImageParameters("/tmp/test.png", "", "invalid", "", 0) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid image quality") + }) + + t.Run("Valid background values should pass", func(t *testing.T) { + validBackgrounds := []string{"opaque", "transparent"} + for _, background := range validBackgrounds { + err := validateImageParameters("/tmp/test.png", "", "", background, 0) + assert.NoError(t, err, "Background %s should be valid", background) + } + }) + + t.Run("Invalid background should fail", func(t *testing.T) { + err := validateImageParameters("/tmp/test.png", "", "", "invalid", 0) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid image background") + }) + + t.Run("Compression for JPEG should pass", func(t *testing.T) { + err := validateImageParameters("/tmp/test.jpg", "", "", "", 75) + assert.NoError(t, err) + }) + + t.Run("Compression for WebP should pass", func(t *testing.T) { + err := validateImageParameters("/tmp/test.webp", "", "", "", 50) + assert.NoError(t, err) + }) + + t.Run("Compression for PNG should fail", func(t *testing.T) { + err := validateImageParameters("/tmp/test.png", "", "", "", 75) + assert.Error(t, err) + assert.Contains(t, err.Error(), "image compression can only be used with JPEG and WebP formats") + }) + + t.Run("Invalid compression range should fail", func(t *testing.T) { + err := validateImageParameters("/tmp/test.jpg", "", "", "", 150) + assert.Error(t, err) + assert.Contains(t, err.Error(), "image compression must be between 0 and 100") + + err = validateImageParameters("/tmp/test.jpg", "", "", "", -10) + assert.Error(t, err) + assert.Contains(t, err.Error(), "image compression must be between 0 and 100") + }) + + t.Run("Transparent background for PNG should pass", func(t *testing.T) { + err := validateImageParameters("/tmp/test.png", "", "", "transparent", 0) + assert.NoError(t, err) + }) + + t.Run("Transparent background for WebP should pass", func(t *testing.T) { + err := validateImageParameters("/tmp/test.webp", "", "", "transparent", 0) + assert.NoError(t, err) + }) + + t.Run("Transparent background for JPEG should fail", func(t *testing.T) { + err := validateImageParameters("/tmp/test.jpg", "", "", "transparent", 0) + assert.Error(t, err) + assert.Contains(t, err.Error(), "transparent background can only be used with PNG and WebP formats") + }) +} + +func TestBuildChatOptionsWithImageParameters(t *testing.T) { + t.Run("Valid image parameters should pass", func(t *testing.T) { + flags := &Flags{ + ImageFile: "/tmp/test.png", + ImageSize: "1024x1024", + ImageQuality: "high", + ImageBackground: "transparent", + ImageCompression: 0, // Not set for PNG + } + + options, err := flags.BuildChatOptions() + assert.NoError(t, err) + assert.NotNil(t, options) + assert.Equal(t, "/tmp/test.png", options.ImageFile) + assert.Equal(t, "1024x1024", options.ImageSize) + assert.Equal(t, "high", options.ImageQuality) + assert.Equal(t, "transparent", options.ImageBackground) + assert.Equal(t, 0, options.ImageCompression) + }) + + t.Run("Invalid image parameters should fail", func(t *testing.T) { + flags := &Flags{ + ImageFile: "/tmp/test.png", + ImageSize: "invalid", + ImageQuality: "high", + ImageBackground: "transparent", + } + + options, err := flags.BuildChatOptions() + assert.Error(t, err) + assert.Nil(t, options) + assert.Contains(t, err.Error(), "invalid image size") + }) + + t.Run("JPEG with compression should pass", func(t *testing.T) { + flags := &Flags{ + ImageFile: "/tmp/test.jpg", + ImageSize: "1536x1024", + ImageQuality: "medium", + ImageBackground: "opaque", + ImageCompression: 80, + } + + options, err := flags.BuildChatOptions() + assert.NoError(t, err) + assert.NotNil(t, options) + assert.Equal(t, 80, options.ImageCompression) + }) + + t.Run("Image parameters without image file should fail in BuildChatOptions", func(t *testing.T) { + flags := &Flags{ + ImageSize: "1024x1024", // Image parameter without ImageFile + } + + options, err := flags.BuildChatOptions() + assert.Error(t, err) + assert.Nil(t, options) + assert.Contains(t, err.Error(), "image parameters") + assert.Contains(t, err.Error(), "can only be used with --image-file") + }) +} diff --git a/common/domain.go b/common/domain.go index 0488eeee..438ed5c1 100644 --- a/common/domain.go +++ b/common/domain.go @@ -29,6 +29,10 @@ type ChatOptions struct { Search bool SearchLocation string ImageFile string + ImageSize string + ImageQuality string + ImageCompression int + ImageBackground string } // NormalizeMessages remove empty messages and ensure messages order user-assist-user diff --git a/completions/_fabric b/completions/_fabric index 516540f1..8a008721 100644 --- a/completions/_fabric +++ b/completions/_fabric @@ -99,6 +99,10 @@ _fabric() { '(--search)--search[Enable web search tool for supported models (Anthropic, OpenAI)]' \ '(--search-location)--search-location[Set location for web search results]:location:' \ '(--image-file)--image-file[Save generated image to specified file path]:image file:_files -g "*.png *.webp *.jpeg *.jpg"' \ + '(--image-size)--image-size[Image dimensions]:size:(1024x1024 1536x1024 1024x1536 auto)' \ + '(--image-quality)--image-quality[Image quality]:quality:(low medium high auto)' \ + '(--image-compression)--image-compression[Compression level 0-100 for JPEG/WebP formats]:compression:' \ + '(--image-background)--image-background[Background type]:background:(opaque transparent)' \ '(--listextensions)--listextensions[List all registered extensions]' \ '(--addextension)--addextension[Register a new extension from config file path]:config file:_files -g "*.yaml *.yml"' \ '(--rmextension)--rmextension[Remove a registered extension by name]:extension:_fabric_extensions' \ diff --git a/completions/fabric.bash b/completions/fabric.bash index a247df76..cce71654 100644 --- a/completions/fabric.bash +++ b/completions/fabric.bash @@ -13,7 +13,7 @@ _fabric() { _get_comp_words_by_ref -n : cur prev words cword # Define all possible options/flags - local opts="--pattern -p --variable -v --context -C --session --attachment -a --setup -S --temperature -t --topp -T --stream -s --presencepenalty -P --raw -r --frequencypenalty -F --listpatterns -l --listmodels -L --listcontexts -x --listsessions -X --updatepatterns -U --copy -c --model -m --modelContextLength --output -o --output-session --latest -n --changeDefaultModel -d --youtube -y --playlist --transcript --transcript-with-timestamps --comments --metadata --language -g --scrape_url -u --scrape_question -q --seed -e --wipecontext -w --wipesession -W --printcontext --printsession --readability --input-has-vars --dry-run --serve --serveOllama --address --api-key --config --search --search-location --image-file --version --listextensions --addextension --rmextension --strategy --liststrategies --listvendors --shell-complete-list --help -h" + local opts="--pattern -p --variable -v --context -C --session --attachment -a --setup -S --temperature -t --topp -T --stream -s --presencepenalty -P --raw -r --frequencypenalty -F --listpatterns -l --listmodels -L --listcontexts -x --listsessions -X --updatepatterns -U --copy -c --model -m --modelContextLength --output -o --output-session --latest -n --changeDefaultModel -d --youtube -y --playlist --transcript --transcript-with-timestamps --comments --metadata --language -g --scrape_url -u --scrape_question -q --seed -e --wipecontext -w --wipesession -W --printcontext --printsession --readability --input-has-vars --dry-run --serve --serveOllama --address --api-key --config --search --search-location --image-file --image-size --image-quality --image-compression --image-background --version --listextensions --addextension --rmextension --strategy --liststrategies --listvendors --shell-complete-list --help -h" # Helper function for dynamic completions _fabric_get_list() { @@ -67,8 +67,21 @@ _fabric() { _filedir return 0 ;; + # Image generation options with specific values + --image-size) + COMPREPLY=($(compgen -W "1024x1024 1536x1024 1024x1536 auto" -- "$cur")) + return 0 + ;; + --image-quality) + COMPREPLY=($(compgen -W "low medium high auto" -- "$cur")) + return 0 + ;; + --image-background) + COMPREPLY=($(compgen -W "opaque transparent" -- "$cur")) + return 0 + ;; # Options requiring simple arguments (no specific completion logic here) - -v | --variable | -t | --temperature | -T | --topp | -P | --presencepenalty | -F | --frequencypenalty | --modelContextLength | -n | --latest | -y | --youtube | -g | --language | -u | --scrape_url | -q | --scrape_question | -e | --seed | --address | --api-key | --search-location) + -v | --variable | -t | --temperature | -T | --topp | -P | --presencepenalty | -F | --frequencypenalty | --modelContextLength | -n | --latest | -y | --youtube | -g | --language | -u | --scrape_url | -q | --scrape_question | -e | --seed | --address | --api-key | --search-location | --image-compression) # No specific completion suggestions, user types the value return 0 ;; diff --git a/completions/fabric.fish b/completions/fabric.fish index a1ec65a5..4468aa14 100755 --- a/completions/fabric.fish +++ b/completions/fabric.fish @@ -62,6 +62,10 @@ complete -c fabric -l api-key -d "API key used to secure server routes" complete -c fabric -l config -d "Path to YAML config file" -r -a "*.yaml *.yml" complete -c fabric -l search-location -d "Set location for web search results (e.g., 'America/Los_Angeles')" complete -c fabric -l image-file -d "Save generated image to specified file path (e.g., 'output.png')" -r -a "*.png *.webp *.jpeg *.jpg" +complete -c fabric -l image-size -d "Image dimensions: 1024x1024, 1536x1024, 1024x1536, auto (default: auto)" -a "1024x1024 1536x1024 1024x1536 auto" +complete -c fabric -l image-quality -d "Image quality: low, medium, high, auto (default: auto)" -a "low medium high auto" +complete -c fabric -l image-compression -d "Compression level 0-100 for JPEG/WebP formats (default: not set)" -r +complete -c fabric -l image-background -d "Background type: opaque, transparent (default: opaque, only for PNG/WebP)" -a "opaque transparent" complete -c fabric -l addextension -d "Register a new extension from config file path" -r -a "*.yaml *.yml" complete -c fabric -l rmextension -d "Remove a registered extension by name" -a "(__fabric_get_extensions)" complete -c fabric -l strategy -d "Choose a strategy from the available strategies" -a "(__fabric_get_strategies)" diff --git a/plugins/ai/openai/openai_image.go b/plugins/ai/openai/openai_image.go index 5309bf20..29ef49e3 100644 --- a/plugins/ai/openai/openai_image.go +++ b/plugins/ai/openai/openai_image.go @@ -11,6 +11,7 @@ import ( "strings" "github.com/danielmiessler/fabric/common" + "github.com/openai/openai-go/packages/param" "github.com/openai/openai-go/responses" ) @@ -64,15 +65,36 @@ func (o *Client) addImageGenerationTool(opts *common.ChatOptions, tools []respon // Check if the request seems to be asking for image generation if o.shouldUseImageGeneration(opts) { outputFormat := getOutputFormatFromExtension(opts.ImageFile) + + // Build the image generation tool with user parameters imageGenTool := responses.ToolUnionParam{ OfImageGeneration: &responses.ToolImageGenerationParam{ Type: ImageGenerationToolType, Model: "gpt-image-1", OutputFormat: outputFormat, - Quality: "auto", - Size: "auto", }, } + + // Set quality if specified by user (otherwise let OpenAI use default) + if opts.ImageQuality != "" { + imageGenTool.OfImageGeneration.Quality = opts.ImageQuality + } + + // Set size if specified by user (otherwise let OpenAI use default) + if opts.ImageSize != "" { + imageGenTool.OfImageGeneration.Size = opts.ImageSize + } + + // Set background if specified by user (otherwise let OpenAI use default) + if opts.ImageBackground != "" { + imageGenTool.OfImageGeneration.Background = opts.ImageBackground + } + + // Set compression if specified by user (only for jpeg/webp) + if opts.ImageCompression != 0 { + imageGenTool.OfImageGeneration.OutputCompression = param.NewOpt(int64(opts.ImageCompression)) + } + tools = append(tools, imageGenTool) } return tools diff --git a/plugins/ai/openai/openai_image_test.go b/plugins/ai/openai/openai_image_test.go index b17e380e..4b4ba0a5 100644 --- a/plugins/ai/openai/openai_image_test.go +++ b/plugins/ai/openai/openai_image_test.go @@ -338,3 +338,107 @@ func TestModelValidationLogic(t *testing.T) { assert.False(t, shouldFail, "Validation should not trigger when no image file is specified") }) } + +func TestAddImageGenerationToolWithUserParameters(t *testing.T) { + client := NewClient() + + tests := []struct { + name string + opts *common.ChatOptions + expected map[string]interface{} + }{ + { + name: "All parameters specified", + opts: &common.ChatOptions{ + ImageFile: "/tmp/test.png", + ImageSize: "1536x1024", + ImageQuality: "high", + ImageBackground: "transparent", + ImageCompression: 0, // Not applicable for PNG + }, + expected: map[string]interface{}{ + "size": "1536x1024", + "quality": "high", + "background": "transparent", + "output_format": "png", + }, + }, + { + name: "JPEG with compression", + opts: &common.ChatOptions{ + ImageFile: "/tmp/test.jpg", + ImageSize: "1024x1024", + ImageQuality: "medium", + ImageBackground: "opaque", + ImageCompression: 75, + }, + expected: map[string]interface{}{ + "size": "1024x1024", + "quality": "medium", + "background": "opaque", + "output_format": "jpeg", + "output_compression": int64(75), + }, + }, + { + name: "Only some parameters specified", + opts: &common.ChatOptions{ + ImageFile: "/tmp/test.webp", + ImageQuality: "low", + }, + expected: map[string]interface{}{ + "quality": "low", + "output_format": "webp", + }, + }, + { + name: "No parameters specified (defaults)", + opts: &common.ChatOptions{ + ImageFile: "/tmp/test.png", + }, + expected: map[string]interface{}{ + "output_format": "png", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tools := client.addImageGenerationTool(tt.opts, []responses.ToolUnionParam{}) + + assert.Len(t, tools, 1) + assert.NotNil(t, tools[0].OfImageGeneration) + + tool := tools[0].OfImageGeneration + + // Check required fields + assert.Equal(t, "gpt-image-1", tool.Model) + assert.Equal(t, tt.expected["output_format"], tool.OutputFormat) + + // Check optional fields + if expectedSize, ok := tt.expected["size"]; ok { + assert.Equal(t, expectedSize, tool.Size) + } else { + assert.Empty(t, tool.Size, "Size should not be set when not specified") + } + + if expectedQuality, ok := tt.expected["quality"]; ok { + assert.Equal(t, expectedQuality, tool.Quality) + } else { + assert.Empty(t, tool.Quality, "Quality should not be set when not specified") + } + + if expectedBackground, ok := tt.expected["background"]; ok { + assert.Equal(t, expectedBackground, tool.Background) + } else { + assert.Empty(t, tool.Background, "Background should not be set when not specified") + } + + if expectedCompression, ok := tt.expected["output_compression"]; ok { + assert.Equal(t, expectedCompression, tool.OutputCompression.Value) + } else { + assert.Equal(t, int64(0), tool.OutputCompression.Value, "Compression should not be set when not specified") + } + }) + } +}