feat(piagent): fall back to concurrent single-image requests when count unsupported

Some image endpoints reject the `n` count parameter (Unknown parameter
tools[0].n / param: n). Detect that error and re-issue the batch as
concurrent count=1 requests, each with a suffixed idempotency key, then
assemble the URLs into a single GeneratedImage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-09 12:26:17 +08:00
parent 568690e2f1
commit 299bac6c1a
2 changed files with 175 additions and 0 deletions
@@ -17,6 +17,7 @@ import (
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"unicode/utf8"
@@ -215,9 +216,83 @@ func (c *ImageClient) Generate(ctx context.Context, prompt string, opts ImageReq
break
}
}
if count > 1 && isImageGenerationCountFallbackError(lastErr) {
logx.Errorf(
"gpt image generation count fallback model=%s size=%s count=%d prompt_runes=%d elapsed=%s idempotency_key=%s err=%v",
model,
size,
count,
promptRunes,
time.Since(startedAt).Round(time.Millisecond),
idempotencyKey,
lastErr,
)
return c.generateImageCountFallback(ctx, prompt, model, size, count, images, 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) {
fallbackCtx, cancel := context.WithCancel(ctx)
defer cancel()
urls := make([]string, count)
var wg sync.WaitGroup
var mu sync.Mutex
var firstErr error
setErr := func(err error) {
if err == nil {
return
}
mu.Lock()
defer mu.Unlock()
if firstErr == nil {
firstErr = err
cancel()
}
}
for index := 0; index < count; index++ {
index := index
wg.Add(1)
go func() {
defer wg.Done()
fallbackOpts := ImageRequestOptions{
Model: model,
Size: size,
Count: 1,
Images: append([]string(nil), images...),
}
if idempotencyKey != "" {
fallbackOpts.IdempotencyKey = fmt.Sprintf("%s-%d", idempotencyKey, index+1)
}
image, err := c.Generate(fallbackCtx, prompt, fallbackOpts)
if err != nil {
setErr(err)
return
}
url := image.URL
if url == "" && len(image.URLs) > 0 {
url = image.URLs[0]
}
if url == "" {
setErr(fmt.Errorf("image generation count fallback returned empty image"))
return
}
urls[index] = url
}()
}
wg.Wait()
mu.Lock()
err := firstErr
mu.Unlock()
if err != nil {
return GeneratedImage{}, err
}
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) {
if len(images) > 0 {
if c.inputImageTransport == "url" && imageInputsCanUseURLTransport(images) {
@@ -787,6 +862,21 @@ func isImageGenerationStreamFallbackError(err error) bool {
strings.Contains(message, "text/event-stream")
}
func isImageGenerationCountFallbackError(err error) bool {
if err == nil {
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"`)
}
func shouldFallbackImageGenerationStream(err error, idempotencyKey string) bool {
if isImageGenerationStreamFallbackError(err) {
return true