feat(piagent): support image quality and output_format in image client
Add quality and output_format to ImageRequestOptions, plumb them through the JSON/multipart request bodies, generalize asset URL collection to non-image formats, and add GenerateLayeredDocument for PSD output. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -303,6 +303,9 @@ func (a *DesignAgent) buildImagePrompts(ctx context.Context, req design.AgentReq
|
||||
|
||||
func plannedGenerationCount(req design.AgentRequest) int {
|
||||
count := req.TaskPlan.ImageCount
|
||||
if count <= 0 && req.ImageCount > 0 {
|
||||
count = req.ImageCount
|
||||
}
|
||||
if count <= 0 && len(req.TaskPlan.ImageTasks) > 0 {
|
||||
count = len(req.TaskPlan.ImageTasks)
|
||||
}
|
||||
@@ -516,6 +519,7 @@ func (a *DesignAgent) generateImageWithFallbackCount(ctx context.Context, req de
|
||||
image, err := a.imageAgent.Generate(ctx, prompt, ImageRequestOptions{
|
||||
Model: req.ImageModel,
|
||||
Size: req.ImageSize,
|
||||
Quality: req.ImageQuality,
|
||||
Count: count,
|
||||
Images: referenceImages,
|
||||
IdempotencyKey: imageGenerationIdempotencyKey(req, index, prompt, false),
|
||||
@@ -532,6 +536,7 @@ func (a *DesignAgent) generateImageWithFallbackCount(ctx context.Context, req de
|
||||
fallbackImage, fallbackErr := a.imageAgent.Generate(ctx, fallback, ImageRequestOptions{
|
||||
Model: req.ImageModel,
|
||||
Size: req.ImageSize,
|
||||
Quality: req.ImageQuality,
|
||||
Count: count,
|
||||
Images: referenceImages,
|
||||
IdempotencyKey: imageGenerationIdempotencyKey(req, index, fallback, true),
|
||||
@@ -553,6 +558,7 @@ func imageGenerationIdempotencyKey(req design.AgentRequest, index int, prompt st
|
||||
strings.TrimSpace(req.Prompt),
|
||||
strings.TrimSpace(req.ImageModel),
|
||||
strings.TrimSpace(req.ImageSize),
|
||||
strings.TrimSpace(req.ImageQuality),
|
||||
strconv.Itoa(plannedGenerationCount(req)),
|
||||
strconv.Itoa(index),
|
||||
kind,
|
||||
|
||||
@@ -46,8 +46,10 @@ type GeneratedImage struct {
|
||||
type ImageRequestOptions struct {
|
||||
Model string
|
||||
Size string
|
||||
Quality string
|
||||
Count int
|
||||
Images []string
|
||||
OutputFormat string
|
||||
IdempotencyKey string
|
||||
}
|
||||
|
||||
@@ -113,6 +115,68 @@ func normalizeInputImageTransport(value string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeImageOutputFormat(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "psd", "png", "jpeg", "jpg", "webp":
|
||||
if strings.EqualFold(strings.TrimSpace(value), "jpg") {
|
||||
return "jpeg"
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(value))
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeImageQuality(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "auto", "low", "medium", "high":
|
||||
return strings.ToLower(strings.TrimSpace(value))
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (c *ImageClient) GenerateLayeredDocument(ctx context.Context, req design.LayeredDocumentRequest) (design.LayeredDocument, error) {
|
||||
imageURL := strings.TrimSpace(req.ImageURL)
|
||||
if imageURL == "" {
|
||||
return design.LayeredDocument{}, fmt.Errorf("layered document image_url is required")
|
||||
}
|
||||
outputFormat := normalizeImageOutputFormat(req.OutputFormat)
|
||||
if outputFormat == "" {
|
||||
outputFormat = "psd"
|
||||
}
|
||||
prompt := strings.TrimSpace(req.Prompt)
|
||||
if prompt == "" {
|
||||
prompt = "Create a layered PSD from the provided image. Preserve the exact canvas and visual design. Put a single clean background layer at the bottom with all text and small foreground elements removed. Put each small standalone foreground element on its own image layer. Keep text as editable text layers when possible. Do not merge text into the background. Return only the layered PSD file."
|
||||
}
|
||||
size := strings.TrimSpace(req.Size)
|
||||
if size == "" {
|
||||
size = "auto"
|
||||
}
|
||||
generated, err := c.Generate(ctx, prompt, ImageRequestOptions{
|
||||
Model: req.Model,
|
||||
Size: size,
|
||||
Count: 1,
|
||||
Images: []string{imageURL},
|
||||
OutputFormat: outputFormat,
|
||||
IdempotencyKey: strings.TrimSpace(req.IdempotencyKey),
|
||||
})
|
||||
if err != nil {
|
||||
return design.LayeredDocument{}, err
|
||||
}
|
||||
content := strings.TrimSpace(generated.URL)
|
||||
if content == "" && len(generated.URLs) > 0 {
|
||||
content = strings.TrimSpace(generated.URLs[0])
|
||||
}
|
||||
if content == "" {
|
||||
return design.LayeredDocument{}, fmt.Errorf("layered document generation returned empty file")
|
||||
}
|
||||
return design.LayeredDocument{
|
||||
Content: content,
|
||||
ContentType: outputFormatContentType(outputFormat),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *ImageClient) Generate(ctx context.Context, prompt string, opts ImageRequestOptions) (GeneratedImage, error) {
|
||||
prompt = strings.TrimSpace(prompt)
|
||||
if prompt == "" {
|
||||
@@ -134,10 +198,26 @@ func (c *ImageClient) Generate(ctx context.Context, prompt string, opts ImageReq
|
||||
count = 10
|
||||
}
|
||||
images := normalizeImageInputs(opts.Images)
|
||||
quality := normalizeImageQuality(opts.Quality)
|
||||
outputFormat := normalizeImageOutputFormat(opts.OutputFormat)
|
||||
|
||||
idempotencyKey := strings.TrimSpace(opts.IdempotencyKey)
|
||||
if count > 1 {
|
||||
logx.Infof(
|
||||
"gpt image generation splitting multi-image request model=%s size=%s count=%d prompt_runes=%d images=%d output_format=%s idempotency_key=%s",
|
||||
model,
|
||||
size,
|
||||
count,
|
||||
utf8.RuneCountInString(prompt),
|
||||
len(images),
|
||||
outputFormat,
|
||||
idempotencyKey,
|
||||
)
|
||||
return c.generateImageCountFallback(ctx, prompt, model, size, quality, count, images, outputFormat, idempotencyKey)
|
||||
}
|
||||
|
||||
streamPlans := []bool{false}
|
||||
if len(images) == 0 && count == 1 {
|
||||
if len(images) == 0 && count == 1 && outputFormat == "" {
|
||||
streamPlans = []bool{true, false}
|
||||
}
|
||||
|
||||
@@ -145,12 +225,12 @@ func (c *ImageClient) Generate(ctx context.Context, prompt string, opts ImageReq
|
||||
startedAt := time.Now()
|
||||
promptRunes := utf8.RuneCountInString(prompt)
|
||||
for _, stream := range streamPlans {
|
||||
body, contentType, err := c.imageRequestBody(ctx, model, prompt, size, count, images, stream)
|
||||
body, contentType, err := c.imageRequestBody(ctx, model, prompt, size, quality, count, images, outputFormat, stream)
|
||||
if err != nil {
|
||||
return GeneratedImage{}, err
|
||||
}
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
image, err := c.generateOnce(ctx, body, contentType, idempotencyKey, len(images) > 0, stream, count)
|
||||
image, err := c.generateOnce(ctx, body, contentType, idempotencyKey, len(images) > 0, stream, count, outputFormat)
|
||||
if err == nil {
|
||||
logx.Infof(
|
||||
"gpt image generation succeeded model=%s size=%s count=%d prompt_runes=%d attempt=%d stream=%t elapsed=%s timeout=%s images=%d idempotency_key=%s",
|
||||
@@ -227,12 +307,12 @@ func (c *ImageClient) Generate(ctx context.Context, prompt string, opts ImageReq
|
||||
idempotencyKey,
|
||||
lastErr,
|
||||
)
|
||||
return c.generateImageCountFallback(ctx, prompt, model, size, count, images, idempotencyKey)
|
||||
return c.generateImageCountFallback(ctx, prompt, model, size, quality, count, images, outputFormat, idempotencyKey)
|
||||
}
|
||||
return GeneratedImage{}, lastErr
|
||||
}
|
||||
|
||||
func (c *ImageClient) generateImageCountFallback(ctx context.Context, prompt string, model string, size string, count int, images []string, idempotencyKey string) (GeneratedImage, error) {
|
||||
func (c *ImageClient) generateImageCountFallback(ctx context.Context, prompt string, model string, size string, quality string, count int, images []string, outputFormat string, idempotencyKey string) (GeneratedImage, error) {
|
||||
fallbackCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
@@ -258,10 +338,12 @@ func (c *ImageClient) generateImageCountFallback(ctx context.Context, prompt str
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
fallbackOpts := ImageRequestOptions{
|
||||
Model: model,
|
||||
Size: size,
|
||||
Count: 1,
|
||||
Images: append([]string(nil), images...),
|
||||
Model: model,
|
||||
Size: size,
|
||||
Quality: quality,
|
||||
Count: 1,
|
||||
Images: append([]string(nil), images...),
|
||||
OutputFormat: outputFormat,
|
||||
}
|
||||
if idempotencyKey != "" {
|
||||
fallbackOpts.IdempotencyKey = fmt.Sprintf("%s-%d", idempotencyKey, index+1)
|
||||
@@ -293,12 +375,12 @@ func (c *ImageClient) generateImageCountFallback(ctx context.Context, prompt str
|
||||
return GeneratedImage{URL: urls[0], URLs: urls}, nil
|
||||
}
|
||||
|
||||
func (c *ImageClient) imageRequestBody(ctx context.Context, model string, prompt string, size string, count int, images []string, stream bool) ([]byte, string, error) {
|
||||
func (c *ImageClient) imageRequestBody(ctx context.Context, model string, prompt string, size string, quality string, count int, images []string, outputFormat string, stream bool) ([]byte, string, error) {
|
||||
if len(images) > 0 {
|
||||
if c.inputImageTransport == "url" && imageInputsCanUseURLTransport(images) {
|
||||
return jsonImageEditBody(model, prompt, size, count, images, stream)
|
||||
return jsonImageEditBody(model, prompt, size, quality, count, images, outputFormat, stream)
|
||||
}
|
||||
return c.multipartImageEditBody(ctx, model, prompt, size, count, images, stream)
|
||||
return c.multipartImageEditBody(ctx, model, prompt, size, quality, count, images, outputFormat, stream)
|
||||
}
|
||||
|
||||
requestPayload := map[string]any{
|
||||
@@ -311,6 +393,12 @@ func (c *ImageClient) imageRequestBody(ctx context.Context, model string, prompt
|
||||
if size != "" {
|
||||
requestPayload["size"] = size
|
||||
}
|
||||
if quality != "" {
|
||||
requestPayload["quality"] = quality
|
||||
}
|
||||
if outputFormat != "" {
|
||||
requestPayload["output_format"] = outputFormat
|
||||
}
|
||||
if stream {
|
||||
requestPayload["stream"] = true
|
||||
}
|
||||
@@ -350,7 +438,7 @@ func imageInputRequiresFileUpload(value string) bool {
|
||||
return netutil.IsLocalOrPrivateHost(host)
|
||||
}
|
||||
|
||||
func jsonImageEditBody(model string, prompt string, size string, count int, images []string, stream bool) ([]byte, string, error) {
|
||||
func jsonImageEditBody(model string, prompt string, size string, quality string, count int, images []string, outputFormat string, stream bool) ([]byte, string, error) {
|
||||
requestPayload := map[string]any{
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
@@ -362,6 +450,12 @@ func jsonImageEditBody(model string, prompt string, size string, count int, imag
|
||||
if size != "" {
|
||||
requestPayload["size"] = size
|
||||
}
|
||||
if quality != "" {
|
||||
requestPayload["quality"] = quality
|
||||
}
|
||||
if outputFormat != "" {
|
||||
requestPayload["output_format"] = outputFormat
|
||||
}
|
||||
if stream {
|
||||
requestPayload["stream"] = true
|
||||
}
|
||||
@@ -369,7 +463,7 @@ func jsonImageEditBody(model string, prompt string, size string, count int, imag
|
||||
return body, "application/json", err
|
||||
}
|
||||
|
||||
func (c *ImageClient) multipartImageEditBody(ctx context.Context, model string, prompt string, size string, count int, images []string, stream bool) ([]byte, string, error) {
|
||||
func (c *ImageClient) multipartImageEditBody(ctx context.Context, model string, prompt string, size string, quality string, count int, images []string, outputFormat string, stream bool) ([]byte, string, error) {
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
fields := map[string]string{
|
||||
@@ -379,9 +473,15 @@ func (c *ImageClient) multipartImageEditBody(ctx context.Context, model string,
|
||||
if size != "" {
|
||||
fields["size"] = size
|
||||
}
|
||||
if quality != "" {
|
||||
fields["quality"] = quality
|
||||
}
|
||||
if count > 1 {
|
||||
fields["n"] = strconv.Itoa(count)
|
||||
}
|
||||
if outputFormat != "" {
|
||||
fields["output_format"] = outputFormat
|
||||
}
|
||||
if stream {
|
||||
fields["stream"] = "true"
|
||||
}
|
||||
@@ -557,7 +657,7 @@ func imageExtension(contentType string, fallback string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *ImageClient) generateOnce(ctx context.Context, body []byte, contentType string, idempotencyKey string, edit bool, stream bool, expectedCount int) (GeneratedImage, error) {
|
||||
func (c *ImageClient) generateOnce(ctx context.Context, body []byte, contentType string, idempotencyKey string, edit bool, stream bool, expectedCount int, outputFormat string) (GeneratedImage, error) {
|
||||
endpoint := "/v1/images/generations"
|
||||
if edit {
|
||||
endpoint = "/v1/images/edits"
|
||||
@@ -603,7 +703,7 @@ func (c *ImageClient) generateOnce(ctx context.Context, body []byte, contentType
|
||||
if err := json.NewDecoder(resp.Body).Decode(&responsePayload); err != nil {
|
||||
return GeneratedImage{}, err
|
||||
}
|
||||
urls := collectGeneratedImageURLs(responsePayload)
|
||||
urls := collectGeneratedAssetURLs(responsePayload, outputFormat)
|
||||
if len(urls) == 0 {
|
||||
return GeneratedImage{}, fmt.Errorf("image generation returned empty image")
|
||||
}
|
||||
@@ -668,7 +768,7 @@ func decodeImageGenerationStreamOrJSON(body io.Reader, expectedCount int) (Gener
|
||||
if message := imageStreamFailureMessage(payload); message != "" {
|
||||
return true, fmt.Errorf("image generation stream failed: %s", message)
|
||||
}
|
||||
appendURLs(collectGeneratedImageURLs(payload))
|
||||
appendURLs(collectGeneratedAssetURLs(payload, ""))
|
||||
return len(urls) >= expectedCount, nil
|
||||
}
|
||||
|
||||
@@ -716,7 +816,7 @@ func decodeImageGenerationStreamOrJSON(body io.Reader, expectedCount int) (Gener
|
||||
if err := json.Unmarshal([]byte(raw.String()), &responsePayload); err != nil {
|
||||
return GeneratedImage{}, err
|
||||
}
|
||||
urls = collectGeneratedImageURLs(responsePayload)
|
||||
urls = collectGeneratedAssetURLs(responsePayload, "")
|
||||
if len(urls) == 0 {
|
||||
return GeneratedImage{}, fmt.Errorf("image generation returned empty image")
|
||||
}
|
||||
@@ -786,6 +886,10 @@ func imageErrorMessage(value any) string {
|
||||
}
|
||||
|
||||
func collectGeneratedImageURLs(payload any) []string {
|
||||
return collectGeneratedAssetURLs(payload, "")
|
||||
}
|
||||
|
||||
func collectGeneratedAssetURLs(payload any, outputFormat string) []string {
|
||||
urls := make([]string, 0)
|
||||
seen := make(map[string]struct{})
|
||||
var walk func(any, string)
|
||||
@@ -800,7 +904,7 @@ func collectGeneratedImageURLs(payload any) []string {
|
||||
walk(childValue, key)
|
||||
}
|
||||
case string:
|
||||
if imageURL := generatedImageString(key, typed); imageURL != "" {
|
||||
if imageURL := generatedAssetString(key, typed, outputFormat); imageURL != "" {
|
||||
if _, ok := seen[imageURL]; ok {
|
||||
return
|
||||
}
|
||||
@@ -814,11 +918,15 @@ func collectGeneratedImageURLs(payload any) []string {
|
||||
}
|
||||
|
||||
func generatedImageString(key string, value string) string {
|
||||
return generatedAssetString(key, value, "")
|
||||
}
|
||||
|
||||
func generatedAssetString(key string, value string, outputFormat string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(value, "data:image/") {
|
||||
if strings.HasPrefix(value, "data:") {
|
||||
return value
|
||||
}
|
||||
normalizedKey := strings.ToLower(strings.TrimSpace(key))
|
||||
@@ -826,14 +934,34 @@ func generatedImageString(key string, value string) string {
|
||||
return value
|
||||
}
|
||||
if isGeneratedImageBase64Key(normalizedKey) {
|
||||
return "data:image/png;base64," + strings.TrimPrefix(value, "data:image/png;base64,")
|
||||
return "data:" + outputFormatContentType(outputFormat) + ";base64," + stripDataURLPrefix(value)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func stripDataURLPrefix(value string) string {
|
||||
if _, payload, ok := strings.Cut(value, ","); ok && strings.HasPrefix(strings.TrimSpace(value), "data:") {
|
||||
return payload
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func outputFormatContentType(format string) string {
|
||||
switch normalizeImageOutputFormat(format) {
|
||||
case "psd":
|
||||
return "image/vnd.adobe.photoshop"
|
||||
case "jpeg":
|
||||
return "image/jpeg"
|
||||
case "webp":
|
||||
return "image/webp"
|
||||
default:
|
||||
return "image/png"
|
||||
}
|
||||
}
|
||||
|
||||
func isGeneratedImageURLKey(key string) bool {
|
||||
switch key {
|
||||
case "url", "image_url", "imageurl", "image", "output_url", "public_url", "publicurl":
|
||||
case "url", "image_url", "imageurl", "image", "output_url", "public_url", "publicurl", "file_url", "fileurl", "download_url", "downloadurl":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -842,7 +970,7 @@ func isGeneratedImageURLKey(key string) bool {
|
||||
|
||||
func isGeneratedImageBase64Key(key string) bool {
|
||||
switch key {
|
||||
case "b64_json", "b64json", "base64", "image_base64", "imagebase64", "result":
|
||||
case "b64_json", "b64json", "base64", "image_base64", "imagebase64", "file_base64", "filebase64", "file_b64", "fileb64", "result":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -867,14 +995,13 @@ func isImageGenerationCountFallbackError(err error) bool {
|
||||
return false
|
||||
}
|
||||
message := strings.ToLower(err.Error())
|
||||
if !strings.Contains(message, "unknown parameter") {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(message, "tools[0].n") ||
|
||||
strings.Contains(message, "param:n") ||
|
||||
strings.Contains(message, "param: n") ||
|
||||
strings.Contains(message, "parameter: 'n'") ||
|
||||
strings.Contains(message, `parameter: "n"`)
|
||||
return strings.Contains(message, "n > 1") && strings.Contains(message, "stream=false") ||
|
||||
strings.Contains(message, "n > 1") && strings.Contains(message, "non-streaming") ||
|
||||
strings.Contains(message, "unknown parameter") && (strings.Contains(message, "tools[0].n") ||
|
||||
strings.Contains(message, "param:n") ||
|
||||
strings.Contains(message, "param: n") ||
|
||||
strings.Contains(message, "parameter: 'n'") ||
|
||||
strings.Contains(message, `parameter: "n"`))
|
||||
}
|
||||
|
||||
func shouldFallbackImageGenerationStream(err error, idempotencyKey string) bool {
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"img_infinite_canvas/internal/domain/design"
|
||||
)
|
||||
|
||||
func TestNewImageClientDefaultsToTenMinuteTimeout(t *testing.T) {
|
||||
@@ -145,36 +147,140 @@ func TestImageClientSendsExplicitSize(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageClientSendsImageCount(t *testing.T) {
|
||||
func TestImageClientSendsQuality(t *testing.T) {
|
||||
var requestBody map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"data":[{"url":"https://example.com/image-1.png"},{"url":"https://example.com/image-2.png"}]}`))
|
||||
_, _ = w.Write([]byte(`{"data":[{"url":"https://example.com/image.png"}]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewImageClient(ImageClientOptions{BaseURL: server.URL, APIKey: "test-key"})
|
||||
image, err := client.Generate(context.Background(), "create two images", ImageRequestOptions{Model: "gpt-image-2", Count: 2})
|
||||
if err != nil {
|
||||
if _, err := client.Generate(context.Background(), "create an image", ImageRequestOptions{Model: "gpt-image-2", Quality: "high"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if requestBody["n"] != float64(2) {
|
||||
t.Fatalf("expected image request n=2, got %#v", requestBody["n"])
|
||||
}
|
||||
if requestBody["stream"] != nil {
|
||||
t.Fatalf("expected multi-image request to avoid stream, got %#v", requestBody["stream"])
|
||||
}
|
||||
if len(image.URLs) != 2 {
|
||||
t.Fatalf("expected two generated images, got %#v", image.URLs)
|
||||
if requestBody["quality"] != "high" {
|
||||
t.Fatalf("expected quality=high, got %#v", requestBody["quality"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageClientFallsBackToConcurrentSingleImageRequestsWhenCountUnsupported(t *testing.T) {
|
||||
func TestImageClientSendsOutputFormatAndParsesPSDBase64(t *testing.T) {
|
||||
var requestBody map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"data":[{"b64_json":"cHNkLWJ5dGVz"}]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewImageClient(ImageClientOptions{BaseURL: server.URL, APIKey: "test-key"})
|
||||
image, err := client.Generate(context.Background(), "create layered file", ImageRequestOptions{Model: "gpt-image-2", OutputFormat: "psd"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if requestBody["output_format"] != "psd" {
|
||||
t.Fatalf("expected output_format=psd, got %#v", requestBody["output_format"])
|
||||
}
|
||||
if requestBody["stream"] != nil {
|
||||
t.Fatalf("expected psd output to avoid streaming, got %#v", requestBody["stream"])
|
||||
}
|
||||
if image.URL != "data:image/vnd.adobe.photoshop;base64,cHNkLWJ5dGVz" {
|
||||
t.Fatalf("expected psd data url, got %#v", image)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageClientGenerateLayeredDocumentUsesPSDOutput(t *testing.T) {
|
||||
var requestBody map[string]any
|
||||
var requestPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestPath = r.URL.Path
|
||||
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"data":[{"url":"https://example.com/layers.psd"}]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewImageClient(ImageClientOptions{BaseURL: server.URL, APIKey: "test-key", InputImageTransport: "url"})
|
||||
document, err := client.GenerateLayeredDocument(context.Background(), design.LayeredDocumentRequest{
|
||||
ImageURL: "https://example.com/source.png",
|
||||
Prompt: "separate layers",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if requestPath != "/v1/images/edits" {
|
||||
t.Fatalf("expected image edit endpoint, got %s", requestPath)
|
||||
}
|
||||
if requestBody["output_format"] != "psd" || requestBody["size"] != "auto" {
|
||||
t.Fatalf("expected psd auto request, got %#v", requestBody)
|
||||
}
|
||||
if document.Content != "https://example.com/layers.psd" || document.ContentType != "image/vnd.adobe.photoshop" {
|
||||
t.Fatalf("unexpected layered document: %#v", document)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageClientSplitsImageCountIntoStreamedSingleRequests(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
requestBodies := make([]map[string]any, 0, 2)
|
||||
requestKeys := make([]string, 0, 2)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
key := r.Header.Get("Idempotency-Key")
|
||||
imageIndex := strings.TrimPrefix(key, "batch-key-")
|
||||
if imageIndex == key {
|
||||
imageIndex = "unknown"
|
||||
}
|
||||
var requestBody map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mu.Lock()
|
||||
requestBodies = append(requestBodies, requestBody)
|
||||
requestKeys = append(requestKeys, key)
|
||||
mu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = fmt.Fprintf(w, `{"data":[{"url":"https://example.com/image-%s.png"}]}`, imageIndex)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewImageClient(ImageClientOptions{BaseURL: server.URL, APIKey: "test-key"})
|
||||
image, err := client.Generate(context.Background(), "create two images", ImageRequestOptions{Model: "gpt-image-2", Count: 2, IdempotencyKey: "batch-key"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(requestBodies) != 2 {
|
||||
t.Fatalf("expected two single-image requests, got %d", len(requestBodies))
|
||||
}
|
||||
for index, body := range requestBodies {
|
||||
if body["n"] != nil {
|
||||
t.Fatalf("expected split request %d to omit n, got %#v", index, body)
|
||||
}
|
||||
if body["stream"] != true {
|
||||
t.Fatalf("expected split request %d to keep stream=true, got %#v", index, body)
|
||||
}
|
||||
}
|
||||
keySet := map[string]bool{}
|
||||
for _, key := range requestKeys {
|
||||
keySet[key] = true
|
||||
}
|
||||
if !keySet["batch-key-1"] || !keySet["batch-key-2"] {
|
||||
t.Fatalf("expected suffixed idempotency keys, got %#v", requestKeys)
|
||||
}
|
||||
if len(image.URLs) != 2 || image.URLs[0] != "https://example.com/image-1.png" || image.URLs[1] != "https://example.com/image-2.png" {
|
||||
t.Fatalf("expected ordered generated images, got %#v", image.URLs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageClientSplitsImageCountRequestsConcurrently(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var initialBody map[string]any
|
||||
fallbackBodies := make([]map[string]any, 0, 3)
|
||||
fallbackKeys := make([]string, 0, 3)
|
||||
activeFallbacks := 0
|
||||
@@ -188,13 +294,6 @@ func TestImageClientFallsBackToConcurrentSingleImageRequestsWhenCountUnsupported
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if requestBody["n"] != nil {
|
||||
mu.Lock()
|
||||
initialBody = requestBody
|
||||
mu.Unlock()
|
||||
http.Error(w, `{"error":{"message":"Unknown parameter: 'tools[0].n'.","param":"tools[0].n","type":"invalid_request_error"}}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
fallbackBodies = append(fallbackBodies, requestBody)
|
||||
@@ -229,22 +328,19 @@ func TestImageClientFallsBackToConcurrentSingleImageRequestsWhenCountUnsupported
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if initialBody["n"] != float64(3) {
|
||||
t.Fatalf("expected first request n=3, got %#v", initialBody["n"])
|
||||
}
|
||||
if len(image.URLs) != 3 {
|
||||
t.Fatalf("expected three fallback images, got %#v", image.URLs)
|
||||
t.Fatalf("expected three generated images, got %#v", image.URLs)
|
||||
}
|
||||
if len(fallbackBodies) != 3 {
|
||||
t.Fatalf("expected three single-image fallback requests, got %d", len(fallbackBodies))
|
||||
t.Fatalf("expected three single-image requests, got %d", len(fallbackBodies))
|
||||
}
|
||||
if maxActiveFallbacks < 2 {
|
||||
t.Fatalf("expected concurrent fallback requests, max active was %d", maxActiveFallbacks)
|
||||
t.Fatalf("expected concurrent split requests, max active was %d", maxActiveFallbacks)
|
||||
}
|
||||
keySet := map[string]bool{}
|
||||
for index, body := range fallbackBodies {
|
||||
if body["n"] != nil {
|
||||
t.Fatalf("expected fallback request %d to omit n, got %#v", index, body)
|
||||
t.Fatalf("expected split request %d to omit n, got %#v", index, body)
|
||||
}
|
||||
keySet[fallbackKeys[index]] = true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user