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:
2026-07-09 20:42:55 +08:00
parent cb24ffd185
commit 41208e566c
3 changed files with 288 additions and 59 deletions
@@ -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 {