docs: add batch image PR readiness notes

This commit is contained in:
Turtle_Li
2026-07-07 03:31:39 +08:00
parent 1b07fe821a
commit 3c43fdec11
20 changed files with 879 additions and 52 deletions
+12 -4
View File
@@ -180,7 +180,11 @@ type BatchImageConfig struct {
Enabled bool `mapstructure:"enabled"`
MaxItemsPerJobDefault int `mapstructure:"max_items_per_job_default"`
MaxItemsPerJobTrial int `mapstructure:"max_items_per_job_trial"`
MaxOutputImagesPerJob int `mapstructure:"max_output_images_per_job"`
MaxOutputImagesPerItem int `mapstructure:"max_output_images_per_item"`
MaxPromptCharsPerItem int `mapstructure:"max_prompt_chars_per_item"`
MaxReferenceImagesPerJob int `mapstructure:"max_reference_images_per_job"`
MaxReferenceInlineBytesPerJob int `mapstructure:"max_reference_inline_bytes_per_job"`
DefaultResponseMimeType string `mapstructure:"default_response_mime_type"`
DefaultImageSize string `mapstructure:"default_image_size"`
MaxDownloadItemsZip int `mapstructure:"max_download_items_zip"`
@@ -1781,15 +1785,19 @@ func setDefaults() {
// Batch Image queue
viper.SetDefault("batch_image.enabled", false)
viper.SetDefault("batch_image.max_items_per_job_default", 500)
viper.SetDefault("batch_image.max_items_per_job_default", 200)
viper.SetDefault("batch_image.max_items_per_job_trial", 50)
viper.SetDefault("batch_image.max_output_images_per_job", 200)
viper.SetDefault("batch_image.max_output_images_per_item", 4)
viper.SetDefault("batch_image.max_prompt_chars_per_item", 8000)
viper.SetDefault("batch_image.max_reference_images_per_job", 1000)
viper.SetDefault("batch_image.max_reference_inline_bytes_per_job", 134217728)
viper.SetDefault("batch_image.default_response_mime_type", "image/png")
viper.SetDefault("batch_image.default_image_size", "1K")
viper.SetDefault("batch_image.max_download_items_zip", 1000)
viper.SetDefault("batch_image.max_download_bytes_per_request", 2147483648)
viper.SetDefault("batch_image.max_download_items_zip", 200)
viper.SetDefault("batch_image.max_download_bytes_per_request", 536870912)
viper.SetDefault("batch_image.max_download_duration_seconds", 600)
viper.SetDefault("batch_image.max_download_concurrency_per_user", 2)
viper.SetDefault("batch_image.max_download_concurrency_per_user", 1)
viper.SetDefault("batch_image.input_retention_after_terminal_hours", 24)
viper.SetDefault("batch_image.output_retention_after_terminal_hours", 72)
viper.SetDefault("batch_image.output_retention_max_days", 7)
+5
View File
@@ -70,6 +70,10 @@ var (
ErrBatchImageInvalidItems = infraerrors.New(http.StatusBadRequest, "BATCH_IMAGE_INVALID_ITEMS", "batch image items are invalid")
ErrBatchImageDuplicateCustomIDInRequest = infraerrors.New(http.StatusBadRequest, "BATCH_IMAGE_DUPLICATE_CUSTOM_ID", "batch image custom ids must be unique")
ErrBatchImagePromptTooLong = infraerrors.New(http.StatusBadRequest, "BATCH_IMAGE_PROMPT_TOO_LONG", "batch image prompt is too long")
ErrBatchImageInvalidReferenceImage = infraerrors.New(http.StatusBadRequest, "BATCH_IMAGE_INVALID_REFERENCE_IMAGE", "batch image reference image is invalid")
ErrBatchImageTooManyReferenceImages = infraerrors.New(http.StatusBadRequest, "BATCH_IMAGE_TOO_MANY_REFERENCE_IMAGES", "too many batch image reference images for this model")
ErrBatchImageReferenceImagesTooLarge = infraerrors.New(http.StatusBadRequest, "BATCH_IMAGE_REFERENCE_IMAGES_TOO_LARGE", "batch image reference images are too large")
ErrBatchImageTooManyOutputImages = infraerrors.New(http.StatusBadRequest, "BATCH_IMAGE_TOO_MANY_OUTPUT_IMAGES", "too many batch image output images")
ErrBatchImageProviderSubmitFailed = infraerrors.New(http.StatusBadGateway, "BATCH_IMAGE_PROVIDER_SUBMIT_FAILED", "batch image provider submit failed")
ErrBatchImageQueueFailed = infraerrors.New(http.StatusBadGateway, "BATCH_IMAGE_QUEUE_FAILED", "batch image queue failed")
ErrBatchImageIdempotencyConflict = infraerrors.New(http.StatusConflict, "BATCH_IMAGE_IDEMPOTENCY_CONFLICT", "idempotency key reused with different batch image request")
@@ -83,6 +87,7 @@ var (
ErrBatchImageResultMissing = infraerrors.New(http.StatusInternalServerError, "BATCH_IMAGE_RESULT_MISSING", "batch image result is missing")
ErrBatchImageDownloadLimited = infraerrors.New(http.StatusTooManyRequests, "BATCH_IMAGE_DOWNLOAD_LIMITED", "too many batch image downloads")
ErrBatchImageDownloadFailed = infraerrors.New(http.StatusInternalServerError, "BATCH_IMAGE_DOWNLOAD_FAILED", "batch image download failed")
ErrBatchImageDownloadTooLarge = infraerrors.New(http.StatusBadRequest, "BATCH_IMAGE_DOWNLOAD_TOO_LARGE", "batch image download is too large")
ErrBatchImageItemImageIndexOutOfRange = infraerrors.New(http.StatusBadRequest, "BATCH_IMAGE_ITEM_IMAGE_INDEX_OUT_OF_RANGE", "batch image item image index is out of range")
ErrBatchImageZipTooManyItems = infraerrors.New(http.StatusBadRequest, "BATCH_IMAGE_ZIP_TOO_MANY_ITEMS", "batch image ZIP contains too many items; use single item downloads")
ErrBatchImageOutputDeleteNotReady = infraerrors.New(http.StatusConflict, "BATCH_IMAGE_OUTPUT_DELETE_NOT_READY", "batch image output can only be deleted after completion")
@@ -6,6 +6,7 @@ import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -21,12 +22,15 @@ import (
)
const (
defaultBatchImageZipMaxItems = 1000
defaultBatchImageZipMaxItems = 200
defaultBatchImageZipMaxBytes = 512 * 1024 * 1024
defaultBatchImageDownloadDuration = 10 * time.Minute
defaultBatchImageDownloadConcurrency = 2
defaultBatchImageDownloadConcurrency = 1
batchImageDownloadScannerMaxLineBytes = 16 * 1024 * 1024
)
var errBatchImageDownloadSizeExceeded = errors.New("batch image download size limit exceeded")
type BatchImageDownloadLimiter interface {
Acquire(ctx context.Context, userID string, kind string) (BatchImageDownloadPermit, error)
}
@@ -74,6 +78,24 @@ type BatchImageDownloadService struct {
Config *config.Config
}
type batchImageDownloadLimitWriter struct {
w io.Writer
limit int64
written int64
}
func (w *batchImageDownloadLimitWriter) Write(p []byte) (int, error) {
if w == nil || w.w == nil {
return 0, io.ErrClosedPipe
}
if w.limit > 0 && w.written+int64(len(p)) > w.limit {
return 0, errBatchImageDownloadSizeExceeded
}
n, err := w.w.Write(p)
w.written += int64(n)
return n, err
}
func NewBatchImageDownloadService(repo BatchImageRepository, accountRepo AccountRepository, limiter BatchImageDownloadLimiter, cfg *config.Config) *BatchImageDownloadService {
return &BatchImageDownloadService{
Repo: repo,
@@ -205,10 +227,14 @@ func (s *BatchImageDownloadService) StreamZip(ctx context.Context, owner BatchIm
}
defer cancel()
zipWriter := zip.NewWriter(w)
limitedWriter := &batchImageDownloadLimitWriter{w: w, limit: s.maxDownloadBytes()}
zipWriter := zip.NewWriter(limitedWriter)
result, manifestFiles, zipErrors, err := s.writeZipImages(streamCtx, zipWriter, r, successItems)
if err != nil {
_ = zipWriter.Close()
if errors.Is(err, errBatchImageDownloadSizeExceeded) {
return result, ErrBatchImageDownloadTooLarge.WithCause(err)
}
return result, ErrBatchImageDownloadFailed.WithCause(err)
}
zipErrors = append(zipErrors, batchImageZipErrorsFromItems(failedItems)...)
@@ -221,14 +247,23 @@ func (s *BatchImageDownloadService) StreamZip(ctx context.Context, owner BatchIm
Files: manifestFiles,
}); err != nil {
_ = zipWriter.Close()
if errors.Is(err, errBatchImageDownloadSizeExceeded) {
return result, ErrBatchImageDownloadTooLarge.WithCause(err)
}
return result, ErrBatchImageDownloadFailed.WithCause(err)
}
if err := writeBatchImageZipJSON(zipWriter, "errors.json", zipErrors); err != nil {
_ = zipWriter.Close()
if errors.Is(err, errBatchImageDownloadSizeExceeded) {
return result, ErrBatchImageDownloadTooLarge.WithCause(err)
}
return result, ErrBatchImageDownloadFailed.WithCause(err)
}
result.ErrorCount = len(zipErrors)
if err := zipWriter.Close(); err != nil {
if errors.Is(err, errBatchImageDownloadSizeExceeded) {
return result, ErrBatchImageDownloadTooLarge.WithCause(err)
}
return result, ErrBatchImageDownloadFailed.WithCause(err)
}
return result, nil
@@ -369,6 +404,13 @@ func (s *BatchImageDownloadService) maxZipItems() int {
return defaultBatchImageZipMaxItems
}
func (s *BatchImageDownloadService) maxDownloadBytes() int64 {
if s != nil && s.Config != nil && s.Config.BatchImage.MaxDownloadBytesPerRequest > 0 {
return s.Config.BatchImage.MaxDownloadBytesPerRequest
}
return defaultBatchImageZipMaxBytes
}
func (s *BatchImageDownloadService) maxDownloadDuration() time.Duration {
if s != nil && s.Config != nil && s.Config.BatchImage.MaxDownloadDurationSeconds > 0 {
return time.Duration(s.Config.BatchImage.MaxDownloadDurationSeconds) * time.Second
@@ -88,8 +88,11 @@ type BatchImageInputItem struct {
}
type BatchImageReference struct {
ID string
Type string
MimeType string
Data []byte
FileURI string
}
type BatchProviderJob struct {
@@ -3,6 +3,7 @@ package service
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
@@ -248,7 +249,19 @@ type geminiContent struct {
}
type geminiPart struct {
Text string `json:"text,omitempty"`
Text string `json:"text,omitempty"`
InlineData *geminiInlineData `json:"inlineData,omitempty"`
FileData *geminiFileData `json:"fileData,omitempty"`
}
type geminiInlineData struct {
MimeType string `json:"mimeType"`
Data string `json:"data"`
}
type geminiFileData struct {
MimeType string `json:"mimeType"`
FileURI string `json:"fileUri"`
}
type geminiGenerationConfig struct {
@@ -280,8 +293,9 @@ func BuildGeminiBatchJSONL(input BatchImageInput) ([]byte, error) {
if prompt == "" {
return nil, batchImageProviderInputError("prompt is required for custom_id %q", customID)
}
if len(item.ReferenceImages) > 0 {
return nil, batchImageProviderInputError("reference images are not supported in PR3")
parts, err := batchImageGeminiParts(prompt, item.ReferenceImages)
if err != nil {
return nil, err
}
// TODO(batch-image): add response_mime_type/aspect_ratio/image_size once the
@@ -290,7 +304,7 @@ func BuildGeminiBatchJSONL(input BatchImageInput) ([]byte, error) {
Key: customID,
Request: geminiGenerateRequest{
Contents: []geminiContent{{
Parts: []geminiPart{{Text: prompt}},
Parts: parts,
}},
GenerationConfig: geminiGenerationConfig{
ResponseModalities: []string{"TEXT", "IMAGE"},
@@ -304,6 +318,32 @@ func BuildGeminiBatchJSONL(input BatchImageInput) ([]byte, error) {
return buf.Bytes(), nil
}
func batchImageGeminiParts(prompt string, refs []BatchImageReference) ([]geminiPart, error) {
parts := []geminiPart{{Text: prompt}}
for _, ref := range refs {
mimeType := normalizeBatchImageReferenceMimeType(ref.MimeType)
if mimeType == "" {
return nil, batchImageProviderInputError("reference image mime_type is required")
}
fileURI := strings.TrimSpace(ref.FileURI)
switch {
case len(ref.Data) > 0 && fileURI == "":
parts = append(parts, geminiPart{InlineData: &geminiInlineData{
MimeType: mimeType,
Data: base64.StdEncoding.EncodeToString(ref.Data),
}})
case len(ref.Data) == 0 && fileURI != "":
parts = append(parts, geminiPart{FileData: &geminiFileData{
MimeType: mimeType,
FileURI: fileURI,
}})
default:
return nil, batchImageProviderInputError("reference image must contain exactly one of data or file_uri")
}
}
return parts, nil
}
func mapGeminiBatchState(batch *GeminiBatchJob) *BatchProviderStatus {
state := strings.TrimSpace(batch.State)
normalized := strings.ToUpper(state)
@@ -74,6 +74,33 @@ func TestBuildGeminiBatchJSONL_RejectsEmptyPrompt(t *testing.T) {
require.ErrorIs(t, err, ErrBatchImageProviderInvalidInput)
}
func TestBuildGeminiBatchJSONL_WritesReferenceImages(t *testing.T) {
input := validGeminiBatchInput()
input.Items[0].ReferenceImages = []BatchImageReference{
{MimeType: "image/webp", Data: []byte("webp-bytes")},
{MimeType: "image/jpeg", FileURI: "gs://bucket/refs/style.jpg"},
}
jsonl, err := BuildGeminiBatchJSONL(input)
require.NoError(t, err)
lines := strings.Split(strings.TrimSpace(string(jsonl)), "\n")
require.Len(t, lines, 1)
var got map[string]any
require.NoError(t, json.Unmarshal([]byte(lines[0]), &got))
request := got["request"].(map[string]any)
contents := request["contents"].([]any)
parts := contents[0].(map[string]any)["parts"].([]any)
require.Len(t, parts, 3)
require.Equal(t, "A clean product hero image", parts[0].(map[string]any)["text"])
inlineData := parts[1].(map[string]any)["inlineData"].(map[string]any)
require.Equal(t, "image/webp", inlineData["mimeType"])
require.Equal(t, "d2VicC1ieXRlcw==", inlineData["data"])
fileData := parts[2].(map[string]any)["fileData"].(map[string]any)
require.Equal(t, "image/jpeg", fileData["mimeType"])
require.Equal(t, "gs://bucket/refs/style.jpg", fileData["fileUri"])
}
func TestGeminiProvider_SubmitUploadsJSONLThenCreatesBatch(t *testing.T) {
client := &fakeGeminiBatchClient{
uploaded: &GeminiUploadedFile{Name: "files/input-jsonl"},
@@ -3,6 +3,7 @@ package service
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
@@ -492,15 +493,16 @@ func BuildVertexBatchJSONL(input BatchImageInput) ([]byte, error) {
if prompt == "" {
return nil, batchImageProviderInputError("prompt is required for custom_id %q", customID)
}
if len(item.ReferenceImages) > 0 {
return nil, batchImageProviderInputError("reference images are not supported in PR4")
parts, err := vertexBatchImageParts(prompt, item.ReferenceImages)
if err != nil {
return nil, err
}
line := map[string]any{
"key": customID,
"request": map[string]any{
"contents": []any{map[string]any{
"role": "user",
"parts": []any{map[string]any{"text": prompt}},
"parts": parts,
}},
"generationConfig": map[string]any{
"responseModalities": []string{"TEXT", "IMAGE"},
@@ -514,6 +516,36 @@ func BuildVertexBatchJSONL(input BatchImageInput) ([]byte, error) {
return buf.Bytes(), nil
}
func vertexBatchImageParts(prompt string, refs []BatchImageReference) ([]any, error) {
parts := []any{map[string]any{"text": prompt}}
for _, ref := range refs {
mimeType := normalizeBatchImageReferenceMimeType(ref.MimeType)
if mimeType == "" {
return nil, batchImageProviderInputError("reference image mime_type is required")
}
fileURI := strings.TrimSpace(ref.FileURI)
switch {
case len(ref.Data) > 0 && fileURI == "":
parts = append(parts, map[string]any{
"inlineData": map[string]any{
"mimeType": mimeType,
"data": base64.StdEncoding.EncodeToString(ref.Data),
},
})
case len(ref.Data) == 0 && fileURI != "":
parts = append(parts, map[string]any{
"fileData": map[string]any{
"mimeType": mimeType,
"fileUri": fileURI,
},
})
default:
return nil, batchImageProviderInputError("reference image must contain exactly one of data or file_uri")
}
}
return parts, nil
}
func NormalizeVertexBatchModelPath(model string) string {
model = strings.Trim(strings.TrimSpace(model), "/")
if strings.HasPrefix(model, "publishers/") || strings.HasPrefix(model, "projects/") {
@@ -72,6 +72,33 @@ func TestBuildVertexBatchJSONL_RejectsEmptyPrompt(t *testing.T) {
require.ErrorIs(t, err, ErrBatchImageProviderInvalidInput)
}
func TestBuildVertexBatchJSONL_WritesReferenceImages(t *testing.T) {
input := validVertexBatchInput()
input.Items[0].ReferenceImages = []BatchImageReference{
{MimeType: "image/png", Data: []byte("png-bytes")},
{MimeType: "image/jpeg", FileURI: "gs://bucket/refs/style.jpg"},
}
jsonl, err := BuildVertexBatchJSONL(input)
require.NoError(t, err)
lines := strings.Split(strings.TrimSpace(string(jsonl)), "\n")
require.Len(t, lines, 1)
var got map[string]any
require.NoError(t, json.Unmarshal([]byte(lines[0]), &got))
request := got["request"].(map[string]any)
contents := request["contents"].([]any)
parts := contents[0].(map[string]any)["parts"].([]any)
require.Len(t, parts, 3)
require.Equal(t, "A clean product hero image", parts[0].(map[string]any)["text"])
inlineData := parts[1].(map[string]any)["inlineData"].(map[string]any)
require.Equal(t, "image/png", inlineData["mimeType"])
require.Equal(t, "cG5nLWJ5dGVz", inlineData["data"])
fileData := parts[2].(map[string]any)["fileData"].(map[string]any)
require.Equal(t, "image/jpeg", fileData["mimeType"])
require.Equal(t, "gs://bucket/refs/style.jpg", fileData["fileUri"])
}
func TestNormalizeVertexBatchModelPath(t *testing.T) {
require.Equal(t, "publishers/google/models/gemini-3.1-flash-image", NormalizeVertexBatchModelPath("gemini-3.1-flash-image"))
require.Equal(t, "publishers/google/models/gemini-2.5-flash-image", NormalizeVertexBatchModelPath("publishers/google/models/gemini-2.5-flash-image"))
+168 -7
View File
@@ -17,13 +17,18 @@ import (
)
const (
defaultBatchImageMaxItems = 500
defaultBatchImageMaxItems = 200
defaultBatchImageMaxOutputImages = 200
defaultBatchImageMaxOutputCount = 4
defaultBatchImageMaxPromptChars = 8000
defaultBatchImageResponseMime = "image/png"
defaultBatchImageImageSize = "1K"
defaultBatchImageDiscountMultiplier = 0.5
defaultBatchImageHoldMultiplier = 0.6
maxBatchImagePublicErrorChars = 500
maxBatchImageReferenceImageBytes = 10 * 1024 * 1024
defaultBatchImageMaxReferenceImages = 1000
defaultBatchImageMaxReferenceBytes = 128 * 1024 * 1024
)
type BatchImageAccountSelectionRepository interface {
@@ -53,8 +58,18 @@ type BatchImageSubmitRequest struct {
}
type BatchImageSubmitItem struct {
CustomID string `json:"custom_id"`
Prompt string `json:"prompt"`
CustomID string `json:"custom_id"`
Prompt string `json:"prompt"`
OutputCount int `json:"output_count,omitempty"`
ReferenceImages []BatchImageReferenceInput `json:"reference_images,omitempty"`
}
type BatchImageReferenceInput struct {
ID string `json:"id,omitempty"`
Type string `json:"type,omitempty"`
MimeType string `json:"mime_type"`
Data []byte `json:"data,omitempty"`
FileURI string `json:"file_uri,omitempty"`
}
type BatchImageOwner struct {
@@ -293,7 +308,21 @@ func (s *BatchImagePublicService) Submit(ctx context.Context, owner BatchImageOw
Items: make([]BatchImageInputItem, 0, len(normalized.Items)),
}
for _, item := range normalized.Items {
input.Items = append(input.Items, BatchImageInputItem{CustomID: item.CustomID, Prompt: item.Prompt})
refs := make([]BatchImageReference, 0, len(item.ReferenceImages))
for _, ref := range item.ReferenceImages {
refs = append(refs, BatchImageReference{
ID: ref.ID,
Type: ref.Type,
MimeType: ref.MimeType,
Data: ref.Data,
FileURI: ref.FileURI,
})
}
input.Items = append(input.Items, BatchImageInputItem{
CustomID: item.CustomID,
Prompt: item.Prompt,
ReferenceImages: refs,
})
}
providerJob, err := provider.Submit(ctx, job, account, input)
@@ -662,11 +691,26 @@ func (s *BatchImagePublicService) validateSubmitRequest(req BatchImageSubmitRequ
req.Metadata = sanitizeBatchImageMetadata(req.Metadata)
seen := make(map[string]struct{}, len(req.Items))
totalReferenceImages := 0
totalInlineReferenceBytes := 0
totalOutputImages := 0
expandedItems := make([]BatchImageSubmitItem, 0, len(req.Items))
for i := range req.Items {
req.Items[i].CustomID = strings.TrimSpace(req.Items[i].CustomID)
if req.Items[i].CustomID == "" {
req.Items[i].CustomID = fmt.Sprintf("item_%06d", i+1)
}
outputCount := req.Items[i].OutputCount
if outputCount == 0 {
outputCount = 1
}
if outputCount < 1 || outputCount > s.maxOutputImagesPerItem() {
return req, ErrBatchImageInvalidItems
}
totalOutputImages += outputCount
if totalOutputImages > s.maxOutputImagesPerJob() {
return req, ErrBatchImageTooManyOutputImages
}
req.Items[i].Prompt = strings.TrimSpace(req.Items[i].Prompt)
if req.Items[i].Prompt == "" {
return req, ErrBatchImageInvalidItems
@@ -674,14 +718,103 @@ func (s *BatchImagePublicService) validateSubmitRequest(req BatchImageSubmitRequ
if len(req.Items[i].Prompt) > s.maxPromptChars() {
return req, ErrBatchImagePromptTooLong
}
if _, ok := seen[req.Items[i].CustomID]; ok {
return req, ErrBatchImageDuplicateCustomIDInRequest
referenceCount, inlineReferenceBytes, err := normalizeBatchImageReferenceInputs(req.Model, &req.Items[i])
if err != nil {
return req, err
}
totalReferenceImages += referenceCount * outputCount
if totalReferenceImages > s.maxReferenceImagesPerJob() {
return req, ErrBatchImageTooManyReferenceImages
}
totalInlineReferenceBytes += inlineReferenceBytes * outputCount
if totalInlineReferenceBytes > s.maxReferenceInlineBytesPerJob() {
return req, ErrBatchImageReferenceImagesTooLarge
}
for repeatIndex := 1; repeatIndex <= outputCount; repeatIndex++ {
expanded := req.Items[i]
expanded.OutputCount = 0
if outputCount > 1 {
expanded.CustomID = fmt.Sprintf("%s_%0*d", req.Items[i].CustomID, batchImageRepeatSuffixWidth(outputCount), repeatIndex)
}
if _, ok := seen[expanded.CustomID]; ok {
return req, ErrBatchImageDuplicateCustomIDInRequest
}
seen[expanded.CustomID] = struct{}{}
expandedItems = append(expandedItems, expanded)
}
seen[req.Items[i].CustomID] = struct{}{}
}
req.Items = expandedItems
return req, nil
}
func normalizeBatchImageReferenceInputs(model string, item *BatchImageSubmitItem) (int, int, error) {
if item == nil || len(item.ReferenceImages) == 0 {
return 0, 0, nil
}
maxRefs := maxBatchImageReferenceImagesForModel(model)
if maxRefs <= 0 || len(item.ReferenceImages) > maxRefs {
return 0, 0, ErrBatchImageTooManyReferenceImages
}
out := make([]BatchImageReferenceInput, 0, len(item.ReferenceImages))
inlineBytes := 0
for _, ref := range item.ReferenceImages {
ref.ID = truncateBatchImageMessage(strings.TrimSpace(ref.ID), 80)
ref.Type = truncateBatchImageMessage(strings.TrimSpace(ref.Type), 40)
ref.MimeType = normalizeBatchImageReferenceMimeType(ref.MimeType)
ref.FileURI = strings.TrimSpace(ref.FileURI)
if ref.MimeType == "" {
return 0, 0, ErrBatchImageInvalidReferenceImage
}
if len(ref.Data) == 0 && ref.FileURI == "" {
return 0, 0, ErrBatchImageInvalidReferenceImage
}
if len(ref.Data) > 0 && ref.FileURI != "" {
return 0, 0, ErrBatchImageInvalidReferenceImage
}
if len(ref.Data) > maxBatchImageReferenceImageBytes {
return 0, 0, ErrBatchImageInvalidReferenceImage
}
if ref.FileURI != "" && !strings.HasPrefix(ref.FileURI, "gs://") {
return 0, 0, ErrBatchImageInvalidReferenceImage
}
inlineBytes += len(ref.Data)
out = append(out, ref)
}
item.ReferenceImages = out
return len(out), inlineBytes, nil
}
func normalizeBatchImageReferenceMimeType(v string) string {
switch strings.ToLower(strings.TrimSpace(v)) {
case "image/jpeg", "image/jpg":
return "image/jpeg"
case "image/png":
return "image/png"
case "image/webp":
return "image/webp"
default:
return ""
}
}
func batchImageRepeatSuffixWidth(count int) int {
if count < 10 {
return 2
}
return len(strconv.Itoa(count))
}
func maxBatchImageReferenceImagesForModel(model string) int {
model = strings.ToLower(strings.TrimSpace(model))
if strings.Contains(model, "pro-image") {
return 14
}
if strings.Contains(model, "flash-image") {
return 3
}
return 0
}
func (s *BatchImagePublicService) selectProviderAndAccount(ctx context.Context, owner BatchImageOwner, requestedProvider, model string) (BatchImageProvider, *Account, error) {
providers := batchImageProviderSelectionOrder(requestedProvider)
for _, providerName := range providers {
@@ -840,6 +973,20 @@ func (s *BatchImagePublicService) maxItems() int {
return defaultBatchImageMaxItems
}
func (s *BatchImagePublicService) maxOutputImagesPerJob() int {
if s != nil && s.Config != nil && s.Config.BatchImage.MaxOutputImagesPerJob > 0 {
return s.Config.BatchImage.MaxOutputImagesPerJob
}
return defaultBatchImageMaxOutputImages
}
func (s *BatchImagePublicService) maxOutputImagesPerItem() int {
if s != nil && s.Config != nil && s.Config.BatchImage.MaxOutputImagesPerItem > 0 {
return s.Config.BatchImage.MaxOutputImagesPerItem
}
return defaultBatchImageMaxOutputCount
}
func (s *BatchImagePublicService) maxPromptChars() int {
if s != nil && s.Config != nil && s.Config.BatchImage.MaxPromptCharsPerItem > 0 {
return s.Config.BatchImage.MaxPromptCharsPerItem
@@ -847,6 +994,20 @@ func (s *BatchImagePublicService) maxPromptChars() int {
return defaultBatchImageMaxPromptChars
}
func (s *BatchImagePublicService) maxReferenceImagesPerJob() int {
if s != nil && s.Config != nil && s.Config.BatchImage.MaxReferenceImagesPerJob > 0 {
return s.Config.BatchImage.MaxReferenceImagesPerJob
}
return defaultBatchImageMaxReferenceImages
}
func (s *BatchImagePublicService) maxReferenceInlineBytesPerJob() int {
if s != nil && s.Config != nil && s.Config.BatchImage.MaxReferenceInlineBytesPerJob > 0 {
return s.Config.BatchImage.MaxReferenceInlineBytesPerJob
}
return defaultBatchImageMaxReferenceBytes
}
func (s *BatchImagePublicService) defaultResponseMimeType() string {
if s != nil && s.Config != nil && strings.TrimSpace(s.Config.BatchImage.DefaultResponseMimeType) != "" {
return strings.TrimSpace(s.Config.BatchImage.DefaultResponseMimeType)
@@ -178,6 +178,28 @@ func TestBatchImagePublicService_Submit(t *testing.T) {
require.Equal(t, "item_000002", gemini.submits[0].Items[1].CustomID)
})
t.Run("expands output count into separate billable items", func(t *testing.T) {
svc, repo, _, gemini, _ := newTestBatchImagePublicService(true)
req := validBatchImageSubmitRequest()
req.Items = []BatchImageSubmitItem{
{CustomID: "cover", Prompt: "hero", OutputCount: 3, ReferenceImages: []BatchImageReferenceInput{{MimeType: "image/png", Data: []byte("ref")}}},
}
got, err := svc.Submit(ctx, testBatchImageOwner(), req, "")
require.NoError(t, err)
require.Equal(t, 3, got.ItemCount)
require.InDelta(t, 0.375, got.EstimatedCost, 1e-12)
require.Len(t, gemini.submits, 1)
require.Len(t, gemini.submits[0].Items, 3)
require.Equal(t, []string{"cover_01", "cover_02", "cover_03"}, []string{
gemini.submits[0].Items[0].CustomID,
gemini.submits[0].Items[1].CustomID,
gemini.submits[0].Items[2].CustomID,
})
require.Len(t, gemini.submits[0].Items[0].ReferenceImages, 1)
require.Len(t, repo.items[got.ID], 3)
})
t.Run("validates request fields", func(t *testing.T) {
tests := []struct {
name string
@@ -191,6 +213,24 @@ func TestBatchImagePublicService_Submit(t *testing.T) {
{name: "prompt_too_long", mutate: func(r *BatchImageSubmitRequest) { r.Items[0].Prompt = strings.Repeat("x", 9) }, want: ErrBatchImagePromptTooLong},
{name: "unsupported_provider", mutate: func(r *BatchImageSubmitRequest) { r.Provider = "other" }, want: ErrBatchImageUnsupportedProvider},
{name: "vertex_rejects_2k", mutate: func(r *BatchImageSubmitRequest) { r.Provider = BatchImageProviderVertex; r.ImageSize = "2K" }, want: ErrBatchImageInvalidItems},
{name: "too_many_outputs_per_item", mutate: func(r *BatchImageSubmitRequest) {
r.Items[0].OutputCount = 5
}, want: ErrBatchImageInvalidItems},
{name: "too_many_reference_images_for_flash", mutate: func(r *BatchImageSubmitRequest) {
r.Model = "gemini-2.5-flash-image"
r.Items[0].ReferenceImages = []BatchImageReferenceInput{
{MimeType: "image/png", Data: []byte("1")},
{MimeType: "image/png", Data: []byte("2")},
{MimeType: "image/png", Data: []byte("3")},
{MimeType: "image/png", Data: []byte("4")},
}
}, want: ErrBatchImageTooManyReferenceImages},
{name: "bad_reference_mime", mutate: func(r *BatchImageSubmitRequest) {
r.Items[0].ReferenceImages = []BatchImageReferenceInput{{MimeType: "application/octet-stream", Data: []byte("x")}}
}, want: ErrBatchImageInvalidReferenceImage},
{name: "reference_requires_data_or_file_uri", mutate: func(r *BatchImageSubmitRequest) {
r.Items[0].ReferenceImages = []BatchImageReferenceInput{{MimeType: "image/png"}}
}, want: ErrBatchImageInvalidReferenceImage},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -213,6 +253,48 @@ func TestBatchImagePublicService_Submit(t *testing.T) {
require.ErrorIs(t, err, ErrBatchImageInvalidItems)
})
t.Run("rejects too many output images", func(t *testing.T) {
svc, _, _, _, _ := newTestBatchImagePublicService(true)
svc.Config.BatchImage.MaxOutputImagesPerJob = 3
req := validBatchImageSubmitRequest()
req.Items[0].OutputCount = 2
req.Items[1].OutputCount = 2
_, err := svc.Submit(ctx, testBatchImageOwner(), req, "")
require.ErrorIs(t, err, ErrBatchImageTooManyOutputImages)
})
t.Run("rejects too many reference images across request", func(t *testing.T) {
svc, _, _, _, _ := newTestBatchImagePublicService(true)
svc.Config.BatchImage.MaxReferenceImagesPerJob = 3
req := validBatchImageSubmitRequest()
req.Model = "gemini-2.5-flash-image"
req.Items[0].ReferenceImages = []BatchImageReferenceInput{
{MimeType: "image/png", Data: []byte("1")},
{MimeType: "image/png", Data: []byte("2")},
}
req.Items[1].ReferenceImages = []BatchImageReferenceInput{
{MimeType: "image/png", Data: []byte("3")},
{MimeType: "image/png", Data: []byte("4")},
}
_, err := svc.Submit(ctx, testBatchImageOwner(), req, "")
require.ErrorIs(t, err, ErrBatchImageTooManyReferenceImages)
})
t.Run("rejects too much inline reference image data across request", func(t *testing.T) {
svc, _, _, _, _ := newTestBatchImagePublicService(true)
svc.Config.BatchImage.MaxReferenceImagesPerJob = 10
svc.Config.BatchImage.MaxReferenceInlineBytesPerJob = 4
req := validBatchImageSubmitRequest()
req.Model = "gemini-2.5-flash-image"
req.Items[0].ReferenceImages = []BatchImageReferenceInput{{MimeType: "image/png", Data: []byte("123")}}
req.Items[1].ReferenceImages = []BatchImageReferenceInput{{MimeType: "image/png", Data: []byte("456")}}
_, err := svc.Submit(ctx, testBatchImageOwner(), req, "")
require.ErrorIs(t, err, ErrBatchImageReferenceImagesTooLarge)
})
t.Run("selects requested provider", func(t *testing.T) {
svc, _, _, gemini, vertex := newTestBatchImagePublicService(true)
req := validBatchImageSubmitRequest()