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()
+2 -2
View File
@@ -19,8 +19,8 @@ FROM ${NODE_IMAGE} AS frontend-builder
WORKDIR /app/frontend
# Install pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate
# Install pnpm. Keep this aligned with CI to avoid lockfile metadata drift.
RUN corepack enable && corepack prepare pnpm@9 --activate
# Install dependencies first (better caching)
COPY frontend/package.json frontend/pnpm-lock.yaml ./
+68 -5
View File
@@ -30,7 +30,22 @@ Submit request:
"items": [
{
"custom_id": "cover_001",
"prompt": "A clean product hero image..."
"prompt": "A clean product hero image...",
"output_count": 1,
"reference_images": [
{
"id": "product-front",
"type": "subject",
"mime_type": "image/png",
"data": "<base64 image bytes without a data URL prefix>"
},
{
"id": "style",
"type": "style",
"mime_type": "image/jpeg",
"file_uri": "gs://internal-managed-bucket/batch-image/refs/style.jpg"
}
]
}
],
"image_size": "1K",
@@ -38,6 +53,19 @@ Submit request:
}
```
`reference_images` is optional per item. Inline `data` is a base64 string decoded by the backend; `file_uri` is reserved for internal Google Cloud Storage references and must be a `gs://` URI. Each reference image must use one of `image/png`, `image/jpeg`, or `image/webp`. Current model limits are:
- `gemini-2.5-flash-image` and other Flash Image aliases: up to 3 reference images per item.
- `gemini-3-pro-image` and other Pro Image aliases: up to 14 reference images per item.
- Per batch job: up to 1000 reference image attachments total after `output_count` expansion across all items. This is an internal Sub2API guardrail for request size and cost control, not the generated-image cap and not a Pro Image per-item capability. The generated-output cap is 200 images per job.
- Per batch job: up to 128 MB decoded inline reference image data total. For large batches or repeated reference images, prefer `gs://` `file_uri` references or split the request into multiple jobs.
`output_count` is optional per item and defaults to `1`. It means "repeat this prompt and reference image set N times" rather than relying on Gemini to return multiple images from one upstream request. The backend expands each repeat into a separate provider JSONL line with suffixed custom ids such as `cover_001_01`, `cover_001_02`. Current limits are:
- Per prompt item: up to 4 output images.
- Per batch job: up to 200 expected output images after expansion. This is the hard generated-output cap for a single job; clients and Codex skills must split larger workloads before submission.
- The output-image limit intentionally matches the default ZIP item limit so newly submitted jobs are always downloadable as one ZIP by item count. ZIP byte size is still capped separately by `max_download_bytes_per_request`.
Public batch response:
```json
@@ -136,8 +164,10 @@ MVP billing rules:
- Settlement runs after result indexing.
- Only successful images are charged.
- Failed items are not charged.
- Reference images are sent to Gemini as input and can create small upstream input-token and temporary storage cost. They are counted once per expanded output request when `output_count > 1`, but the public MVP billing model does not add a separate reference-image surcharge. User-facing estimated, held, and settled amounts are still based on the output image count and configured batch image unit price.
- Settlement request id is `batch_image_settlement:{batch_id}`.
- Settlement is idempotent; re-running settlement must not double charge.
- Settlement billing failures are retried with a bounded retry limit. After the retry limit is reached, the job is failed and the remaining hold is released through the idempotent release path.
Exact production pricing is resolved through model pricing configuration and is not defined here.
@@ -170,6 +200,7 @@ For the managed Vertex/GCS batch bucket, disable Cloud Storage soft delete or co
- Uses Gemini Batch API with JSONL file mode.
- Result file refs are internal.
- API keys are never returned.
- The provider can be selected and submitted through Sub2API when an administrator configures a Gemini API-key upstream account. In the 2026-07-07 PR validation, this path was verified as selectable/callable, but successful image generation was not continued because the test API key had no prepayment.
`vertex`:
@@ -179,6 +210,34 @@ For the managed Vertex/GCS batch bucket, disable Cloud Storage soft delete or co
- Batch image output should be treated as `1K`/default only in MVP.
- Do not promise `2K` or `4K`.
## Official Google Enablement
Operators must enable Gemini/Vertex capability in Google's official console before turning on Sub2API batch image for any group. Sub2API feature flags and group switches do not create Google-side access by themselves.
Recommended production path:
- Use a Google Cloud project with billing enabled.
- Enable the relevant Gemini API / Vertex AI APIs for the project.
- Use a service account or Application Default Credentials for the Sub2API runtime.
- Create one fixed Cloud Storage bucket for batch image input and output, then grant the runtime and Vertex service agent the minimum required bucket permissions.
- Configure Sub2API with the project id, location, managed bucket, provider account, model whitelist, and pricing.
- Enable `BATCH_IMAGE_ENABLED` globally and `allow_batch_image_generation` only on the intended Gemini group.
API-key path:
- Google API keys are suitable for Gemini API development and supported Gemini methods.
- The Sub2API `x-goog-api-key` compatibility header still expects a Sub2API key, not a plain Google key.
- Plain Google API keys should not be documented as the default production credential for Vertex service-account batch jobs.
- If an administrator configures a Gemini API-key upstream account, validate it with one low-cost batch image after the Google account has the required billing/prepayment state. If it has no prepayment, record only that the provider is selectable/callable and that failed submit releases hold.
Official references:
- Gemini API key guide: https://ai.google.dev/gemini-api/docs/api-key
- Gemini API Batch API: https://ai.google.dev/gemini-api/docs/batch-api
- Gemini API image generation and batch image notes: https://ai.google.dev/gemini-api/docs/image-generation
- Vertex/Gemini batch inference: https://docs.cloud.google.com/gemini-enterprise-agent-platform/models/capabilities/batch-inference
- Vertex batch predictions API: https://docs.cloud.google.com/gemini-enterprise-agent-platform/reference/models/batch-prediction-api
## Config
These keys exist in `backend/internal/config/config.go`:
@@ -186,16 +245,20 @@ These keys exist in `backend/internal/config/config.go`:
```yaml
batch_image:
enabled: false
max_items_per_job_default: 500
max_items_per_job_default: 200
max_items_per_job_trial: 50
max_output_images_per_job: 200
max_output_images_per_item: 4
max_prompt_chars_per_item: 8000
max_reference_images_per_job: 1000
max_reference_inline_bytes_per_job: 134217728
default_response_mime_type: "image/png"
default_image_size: "1K"
max_download_items_zip: 1000
max_download_bytes_per_request: 2147483648
max_download_items_zip: 200
max_download_bytes_per_request: 536870912
max_download_duration_seconds: 600
max_download_concurrency_per_user: 2
max_download_concurrency_per_user: 1
input_retention_after_terminal_hours: 24
output_retention_after_terminal_hours: 72
+10
View File
@@ -15,6 +15,16 @@ export type BatchImageStatus =
export interface BatchImageSubmitItem {
custom_id: string
prompt: string
output_count?: number
reference_images?: BatchImageReferenceImage[]
}
export interface BatchImageReferenceImage {
id?: string
type?: string
mime_type: string
data?: string
file_uri?: string
}
export interface BatchImageSubmitRequest {
+1 -1
View File
@@ -2312,7 +2312,7 @@ export default {
imageMultiplier: 'Image multiplier',
batchDiscountMultiplier: 'Batch image discount',
batchHoldMultiplier: 'Batch hold price ratio',
batchSectionHint: 'Batch image settings only apply to batch jobs: settlement applies the batch discount, and the upfront hold is normal image price × batch hold price ratio.',
batchSectionHint: 'Batch image settings only apply to batch jobs: settlement applies the batch discount, and the upfront hold is normal image price × batch hold price ratio. Reference images also create upstream input-token usage, so a batch image discount above 0.5 is recommended.',
batchDisabledHint: 'Enable image generation for this group before enabling batch image generation.',
modeHint: 'By default, image billing uses image price × current effective group multiplier. Independent mode uses image price × image multiplier.',
finalPricePreview: 'Final per-image price preview',
+1 -1
View File
@@ -2394,7 +2394,7 @@ export default {
imageMultiplier: '生图独立倍率',
batchDiscountMultiplier: '批量生图折扣倍率',
batchHoldMultiplier: '批量冻结价格比例',
batchSectionHint: '批量生图仅影响批量任务:结算价格会叠加批量折扣倍率,提交时冻结金额按普通生图原价 × 批量冻结价格比例计算。',
batchSectionHint: '批量生图仅影响批量任务:结算价格会叠加批量折扣倍率,提交时冻结金额按普通生图原价 × 批量冻结价格比例计算。参考图也会产生上游输入 token 消耗,建议批量生图折扣倍率设置大于 0.5。',
batchDisabledHint: '请先开启当前分组生图,才能开启批量生图。',
modeHint: '默认关闭独立倍率时,图片费用 = 图片价格 × 当前分组有效倍率;开启独立倍率后,图片费用 = 图片价格 × 生图独立倍率。',
finalPricePreview: '最终单张价格预览',
+253 -17
View File
@@ -587,9 +587,9 @@
</div>
<div>
<label class="input-label">Prompt 数量</label>
<label class="input-label">预计生成</label>
<div class="input flex items-center bg-gray-50 text-gray-600 dark:bg-dark-900 dark:text-gray-300">
{{ parsedItems.length }} 条
{{ estimatedOutputCount }} 张 / {{ promptRows.length }} 条
</div>
</div>
</div>
@@ -600,24 +600,65 @@
<span class="text-xs text-gray-500 dark:text-gray-400">已添加 {{ promptRows.length }} 条</span>
</div>
<div class="rounded-lg border border-gray-200 p-3 dark:border-dark-700">
<div class="grid gap-3 md:grid-cols-[180px_minmax(0,1fr)_96px] md:items-start">
<textarea
v-model="promptDraft"
rows="3"
class="h-[76px] w-full resize-y rounded-md border border-gray-300 px-3 py-2 text-sm leading-5 outline-none focus:border-primary-500 focus:ring-2 focus:ring-primary-100 dark:border-dark-600 dark:bg-dark-900 dark:text-gray-100 dark:focus:border-primary-500 dark:focus:ring-primary-900/40"
placeholder="粘贴 prompt,添加后进入下方列表"
/>
<div class="mt-2 grid gap-2 md:grid-cols-[minmax(0,1fr)_112px_132px_112px] md:items-center">
<input
v-model="customIdDraft"
type="text"
maxlength="255"
class="input"
class="input h-9 text-sm"
placeholder="Custom ID 可选"
/>
<textarea
v-model="promptDraft"
class="min-h-[120px] w-full resize-y rounded-md border border-gray-300 px-3 py-2 text-sm outline-none focus:border-primary-500 focus:ring-2 focus:ring-primary-100 dark:border-dark-600 dark:bg-dark-900 dark:text-gray-100 dark:focus:border-primary-500 dark:focus:ring-primary-900/40"
placeholder="输入一整段 prompt,点击添加后会进入下方列表"
/>
<button type="button" class="btn btn-secondary h-10 justify-center" :disabled="!promptDraft.trim()" @click="addPromptRow">
<select
v-model.number="outputCountDraft"
class="batch-output-count-select input h-9 text-sm"
title="每条生成张数"
aria-label="每条生成张数"
>
<option v-for="count in outputCountOptions" :key="count" :value="count">
{{ count }} 张
</option>
</select>
<label
class="btn btn-secondary h-9 cursor-pointer justify-center text-sm"
:class="referenceImageDrafts.length >= selectedModelReferenceLimit ? 'pointer-events-none opacity-60' : ''"
>
<Icon name="upload" size="sm" class="mr-1.5" />
参考图
<input
type="file"
accept="image/png,image/jpeg,image/webp"
multiple
class="hidden"
:disabled="referenceImageDrafts.length >= selectedModelReferenceLimit"
@change="handleReferenceImageFiles"
/>
</label>
<button type="button" class="btn btn-secondary h-9 justify-center whitespace-nowrap px-4 text-sm" :disabled="!promptDraft.trim()" @click="addPromptRow">
<Icon name="plus" size="sm" class="mr-1.5" />
添加
</button>
</div>
<div v-if="referenceImageDrafts.length" class="mt-3 flex flex-wrap gap-2">
<span
v-for="(ref, refIndex) in referenceImageDrafts"
:key="`${ref.name}-${refIndex}`"
class="inline-flex max-w-full items-center gap-1 rounded-md border border-gray-200 bg-gray-50 px-2 py-1 text-xs text-gray-700 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-200"
>
<span class="max-w-[180px] truncate">{{ ref.name }}</span>
<button type="button" class="text-gray-400 hover:text-red-600" title="移除参考图" @click="removeReferenceImageDraft(refIndex)">
<Icon name="x" size="xs" />
</button>
</span>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
每条最多 {{ BATCH_IMAGE_MAX_OUTPUTS_PER_ITEM }} 张,整组最多 {{ BATCH_IMAGE_MAX_OUTPUTS_PER_JOB }} 张;当前模型每条最多 {{ selectedModelReferenceLimit }} 张参考图,参考图按生成张数重复消耗输入 token。
</p>
</div>
<div v-if="promptRows.length" class="overflow-hidden rounded-lg border border-gray-200 dark:border-dark-700">
<div
@@ -627,6 +668,12 @@
>
<span class="w-20 flex-shrink-0 font-mono text-xs text-gray-500 dark:text-gray-400">{{ row.custom_id }}</span>
<p class="min-w-0 flex-1 truncate text-sm text-gray-800 dark:text-gray-100">{{ row.prompt }}</p>
<span v-if="row.output_count > 1" class="flex-shrink-0 text-xs text-gray-500 dark:text-gray-400">
x{{ row.output_count }}
</span>
<span v-if="row.reference_images.length" class="flex-shrink-0 text-xs text-gray-500 dark:text-gray-400">
{{ row.reference_images.length }} 参考图
</span>
<button type="button" class="btn-ghost btn-icon flex-shrink-0 text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20" title="删除" @click="removePromptRow(index)">
<Icon name="trash" size="sm" />
</button>
@@ -662,9 +709,9 @@
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">当前界面如何使用</h3>
<div class="rounded-lg border border-gray-200 bg-gray-50 p-3 text-sm leading-6 text-gray-700 dark:border-dark-700 dark:bg-dark-900/50 dark:text-gray-200">
<p>1. 选择已开启批量生图的 Gemini API Key,模型列表会按该 Key 所属分组可用模型展示。</p>
<p>2. 任务名称可以留空,提交时会自动使用当前时间;Prompt 需要一条条添加到列表里。</p>
<p>2. 任务名称可以留空,提交时会自动使用当前时间;Prompt 需要一条条添加到列表里,每条 Prompt 可附参考图,也可以设置重复生成张数。</p>
<p>3. 提交后任务会先排队,明细会展示已提交的 Prompt;图片预览默认不加载,点击明细里的预览按钮才会加载单张图。</p>
<p>4. 完成后可以下载 ZIP;部分失败时,更多菜单里可以只重试失败项。</p>
<p>4. 完成后可以下载 ZIP;部分失败时,更多菜单里可以只重试失败项。当前结算仍按成功输出图张数计算,不单独对参考图加价。</p>
</div>
</section>
<section class="space-y-3">
@@ -720,6 +767,7 @@ import {
type BatchImageItem,
type BatchImageJob,
type BatchImageJobsListOptions,
type BatchImageReferenceImage,
type BatchImageStatus,
type BatchImageSubmitItem,
} from '@/api/batchImage'
@@ -742,6 +790,13 @@ type PromptRow = {
localId: string
custom_id: string
prompt: string
output_count: number
reference_images: BatchImageReferenceImage[]
}
type ReferenceImageDraft = BatchImageReferenceImage & {
name: string
size: number
}
type PreviewCacheRecord = {
@@ -762,6 +817,9 @@ const PREVIEW_THUMBNAIL_QUALITY = 0.72
const PREVIEW_CACHE_MAX_AGE_MS = 3 * 24 * 60 * 60 * 1000
const PREVIEW_CACHE_MAX_ENTRIES = 120
const PREVIEW_CACHE_MAX_BYTES = 48 * 1024 * 1024
const BATCH_IMAGE_MAX_OUTPUTS_PER_ITEM = 4
const BATCH_IMAGE_MAX_OUTPUTS_PER_JOB = 200
const outputCountOptions = Array.from({ length: BATCH_IMAGE_MAX_OUTPUTS_PER_ITEM }, (_, index) => index + 1)
const batchPageSizeOptions: SelectOption[] = [20, 50, 100].map(size => ({ value: size, label: String(size) }))
const appStore = useAppStore()
@@ -844,6 +902,8 @@ const expandedParentIds = ref(new Set<string>())
const promptRows = ref<PromptRow[]>([])
const promptDraft = ref('')
const customIdDraft = ref('')
const outputCountDraft = ref(1)
const referenceImageDrafts = ref<ReferenceImageDraft[]>([])
const itemPreviewUrls = reactive<Record<string, string>>({})
const previewLoadingIds = ref(new Set<string>())
const previewErrorIds = ref(new Set<string>())
@@ -962,16 +1022,37 @@ const endpointBase = computed(() => {
return '<你的 Sub2API API 端点>'
})
const selectedModelReferenceLimit = computed(() => referenceImageLimitForModel(form.model))
const estimatedOutputCount = computed(() =>
promptRows.value.reduce((sum, row) => sum + normalizeOutputCount(row.output_count), 0),
)
const parsedItems = computed<BatchImageSubmitItem[]>(() => {
const used = new Set<string>()
return promptRows.value
.map((row, index) => {
const customID = uniqueCustomID(row.custom_id || `img_${String(index + 1).padStart(3, '0')}`, used, index)
return { custom_id: customID, prompt: row.prompt.trim() }
const item: BatchImageSubmitItem = { custom_id: customID, prompt: row.prompt.trim() }
const outputCount = normalizeOutputCount(row.output_count)
if (outputCount > 1) {
item.output_count = outputCount
}
if (row.reference_images.length) {
item.reference_images = row.reference_images
}
return item
})
.filter(item => item.prompt)
})
function referenceImageLimitForModel(model: string) {
const normalized = String(model || '').toLowerCase()
if (normalized.includes('pro-image')) return 14
if (normalized.includes('flash-image')) return 3
return 0
}
const agentInstruction = computed(() => `---
name: sub2api-batch-image
description: 当用户希望用 Gemini/Vertex 批量生成图片、批量跑提示词、下载批量生图结果、重试失败图片时使用。
@@ -986,8 +1067,11 @@ ${endpointBase.value}
1. 从用户聊天或附件中提取 prompt。每条 prompt 保留完整文本,按顺序生成稳定 custom_id,例如 img_001、img_002。
2. 从用户要求或上下文推断任务名称;没有明确名称时用当前时间生成任务名。
3. 从用户要求或上下文推断输出目录;如果用户没有说保存到哪里,才询问用户。
4. 选择 API Key 和模型:先获取当前可用的批量生图 Key/模型;如果用户指定模型且该 Key 支持,则使用用户指定模型;否则使用该 Key 可用模型中的默认/第一个。不要展示或询问内部 provider 名称。
5. 调用批量生图 API 提交、轮询、下载,不要求用户去页面里手填。
4. 提交前必须先计算 expected_output_count = 所有 item 的 output_count 之和。单个批量任务硬性最多 200 张输出图;超过 200 张必须拆成多组任务,不能提交一个超大任务,也不能把参考图附件上限当成生成张数上限。
5. 如果用户提供参考图,把参考图按用途绑定到具体 item。参考图只是输入附件,不是输出图数量。模型单条限制必须按模型执行:Gemini 2.5 Flash Image 每条最多 3 张参考图;Gemini 3 Pro Image 每条最多 14 张参考图。不要把后端附件风控理解成 Pro 单条能力:按 output_count 展开后,所有 item 的参考图附件总数还有内部保护阈值 1000 个,inline base64 参考图解码后总量最多 128MB。这个 1000 只是服务器拒绝异常请求的保护阈值,不是推荐规模;参考图很多或总请求体较大时应主动拆分任务。
6. 参考图会按 output_count 重复消耗输入 token;大量任务、重复复用同一张参考图或参考图总体积较大时,优先使用 gs:// file_uri 或拆分成多组任务。
7. 选择 API Key 和模型:先获取当前可用的批量生图 Key/模型;如果用户指定模型且该 Key 支持,则使用用户指定模型;否则使用该 Key 可用模型中的默认/第一个。不要展示或询问内部 provider 名称。
8. 调用批量生图 API 提交、轮询、下载,不要求用户去页面里手填。
API 调用规范:
- 模型:GET ${joinEndpointPath(endpointBase.value, '/v1/images/batches/models')}
@@ -1004,14 +1088,29 @@ API 调用规范:
"image_size": "1K",
"response_mime_type": "image/png",
"items": [
{ "custom_id": "img_001", "prompt": "<第一条完整 prompt>" }
{
"custom_id": "img_001",
"prompt": "<第一条完整 prompt>",
"output_count": 1,
"reference_images": [
{
"id": "face",
"type": "subject",
"mime_type": "image/png",
"data": "<base64,不含 data:image/png;base64, 前缀>"
}
]
}
]
}
必须遵守:
- 不要把 API Key 写入仓库、日志、提交记录或最终回复。
- 不要把参考图 base64 写入最终回复、日志或公开文件。恢复记录中只保存参考图文件名、用途、数量和请求 JSON 文件路径;若请求 JSON 文件包含 base64,应保存在用户指定输出目录且不要提交到仓库。
- output_count 表示同一 prompt 和参考图重复生成几张,默认 1,每条最多 4;这不是依赖 Gemini 单次请求返回多图,而是系统展开成多个真实任务项。提交前必须确认预计输出图总数不超过 200,超过就拆分成多组任务。绝不能因为参考图附件有更高的内部保护阈值,就提交会生成超过 200 张图的任务。
- 当前对用户的批量生图计费仍按成功输出图片数量结算,不单独对参考图加价。可以向用户说明:参考图会产生少量上游输入 token 和临时存储成本,且会随 output_count 重复计算;页面显示的冻结/结算金额按输出图片数量计算。
- 提交成功后,必须立刻在输出目录写入本地恢复记录,例如 batch-image-resume.json。不要在恢复记录里保存 API Key。
- 恢复记录至少包含:endpoint、task_name、batch_id、model、output_dir、request_file、submitted_at、last_status、status_url、items_url、download_url、prompt_count,以及可用于失败重试的 custom_id 到 prompt 映射或请求 JSON 文件路径。
- 恢复记录至少包含:endpoint、task_name、batch_id、model、output_dir、request_file、submitted_at、last_status、status_url、items_url、download_url、prompt_count、expected_output_count,以及可用于失败重试的 custom_id 到 prompt 映射或请求 JSON 文件路径。
- 每次查询状态后更新恢复记录,写入 last_checked_at、last_status、成功数、失败数、实际扣费和失败摘要。会话中断或暂停后,下次必须能凭该文件继续查询、下载或重试。
- 不要高频轮询。首次查询等待约 20 到 30 秒;queued 状态每 60 到 120 秒查询一次;如果连续 3 次仍是 queued,就先停止主动查询,告诉用户任务仍在排队,并保留恢复记录,之后可继续其他任务或等待用户稍后让你恢复。
- running 状态每约 60 秒查询一次,服务器压力大或大批量任务时可以更久;processing_results 等接近完成的状态可每 20 到 45 秒查询一次。
@@ -1037,9 +1136,16 @@ function uniqueCustomID(raw: string, used: Set<string>, index: number): string {
return candidate
}
function normalizeOutputCount(value: unknown): number {
const parsed = Math.floor(Number(value || 1))
if (!Number.isFinite(parsed)) return 1
return Math.min(BATCH_IMAGE_MAX_OUTPUTS_PER_ITEM, Math.max(1, parsed))
}
function addPromptRow() {
const prompt = promptDraft.value.trim()
if (!prompt) return
const outputCount = normalizeOutputCount(outputCountDraft.value)
const used = new Set(promptRows.value.map(row => row.custom_id))
const customID = uniqueCustomID(customIdDraft.value || `img_${String(promptRows.value.length + 1).padStart(3, '0')}`, used, promptRows.value.length)
promptRows.value = [
@@ -1048,16 +1154,78 @@ function addPromptRow() {
localId: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
custom_id: customID,
prompt,
output_count: outputCount,
reference_images: referenceImageDrafts.value.map(({ name: _name, size: _size, ...ref }) => ref),
},
]
promptDraft.value = ''
customIdDraft.value = ''
outputCountDraft.value = 1
referenceImageDrafts.value = []
}
function removePromptRow(index: number) {
promptRows.value = promptRows.value.filter((_, currentIndex) => currentIndex !== index)
}
function removeReferenceImageDraft(index: number) {
referenceImageDrafts.value = referenceImageDrafts.value.filter((_, currentIndex) => currentIndex !== index)
}
async function handleReferenceImageFiles(event: Event) {
const input = event.target as HTMLInputElement
const files = Array.from(input.files || [])
input.value = ''
if (files.length === 0) return
const limit = selectedModelReferenceLimit.value
if (limit <= 0) {
appStore.showError('当前模型不支持参考图。')
return
}
const slots = Math.max(0, limit - referenceImageDrafts.value.length)
if (slots <= 0) {
appStore.showError(`当前模型每条最多 ${limit} 张参考图。`)
return
}
const accepted = files.slice(0, slots)
if (accepted.length < files.length) {
appStore.showError(`当前模型每条最多 ${limit} 张参考图,已忽略超出的文件。`)
}
const next: ReferenceImageDraft[] = []
for (const file of accepted) {
if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type)) {
appStore.showError('参考图仅支持 PNG、JPEG 或 WebP。')
continue
}
if (file.size > 10 * 1024 * 1024) {
appStore.showError(`${file.name} 超过 10MB,已忽略。`)
continue
}
const data = await readFileAsBase64(file)
next.push({
id: file.name,
type: 'reference',
mime_type: file.type,
data,
name: file.name,
size: file.size,
})
}
referenceImageDrafts.value = [...referenceImageDrafts.value, ...next]
}
function readFileAsBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onerror = () => reject(reader.error || new Error('Failed to read file'))
reader.onload = () => {
const result = String(reader.result || '')
resolve(result.includes(',') ? result.slice(result.indexOf(',') + 1) : result)
}
reader.readAsDataURL(file)
})
}
async function loadApiKeys() {
loadingKeys.value = true
try {
@@ -1382,14 +1550,19 @@ function openCreateModal() {
}
function closeCreateModal() {
if (submitting.value) return
showCreateModal.value = false
resetCreateDraft()
}
function resetCreateDraft() {
form.taskName = ''
form.responseMimeType = 'image/png'
promptRows.value = []
promptDraft.value = ''
customIdDraft.value = ''
outputCountDraft.value = 1
referenceImageDrafts.value = []
}
function closeDetail() {
@@ -1427,6 +1600,15 @@ function validateForm(): boolean {
appStore.showError(batchImageText('promptRequired'))
return false
}
if (estimatedOutputCount.value > BATCH_IMAGE_MAX_OUTPUTS_PER_JOB) {
appStore.showError(batchImageText('tooManyOutputImages'))
return false
}
const refLimit = selectedModelReferenceLimit.value
if (promptRows.value.some(row => row.reference_images.length > refLimit)) {
appStore.showError(batchImageText('tooManyReferenceImages'))
return false
}
return true
}
@@ -2245,6 +2427,10 @@ type BatchImageTextKey =
| 'invalidItems'
| 'duplicateCustomId'
| 'promptTooLong'
| 'invalidReferenceImage'
| 'tooManyReferenceImages'
| 'referenceImagesTooLarge'
| 'tooManyOutputImages'
| 'idempotencyConflict'
| 'notReady'
| 'outputDeleted'
@@ -2252,6 +2438,7 @@ type BatchImageTextKey =
| 'itemFailed'
| 'itemImageIndexOutOfRange'
| 'downloadLimited'
| 'downloadTooLarge'
| 'deleteNotReady'
| 'disabled'
| 'authRequired'
@@ -2305,6 +2492,10 @@ function batchImageText(key: BatchImageTextKey) {
invalidItems: 'Prompt 列表格式不正确,请检查是否为空、是否超过数量限制,或图片尺寸是否仍为 1K。',
duplicateCustomId: 'Prompt 列表里的 custom_id 不能重复。',
promptTooLong: '单条 prompt 过长,请缩短后重试。',
invalidReferenceImage: '参考图格式不正确,请使用 10MB 以内的 PNG、JPEG 或 WebP。',
tooManyReferenceImages: '参考图数量超过限制:Flash Image 每条最多 3 张,Pro Image 每条最多 14 张,整组最多 1000 张。',
referenceImagesTooLarge: '参考图总量过大。inline 参考图整组最多 128MB;大量参考图请改用 gs:// file_uri 或拆分任务。',
tooManyOutputImages: '预计生成张数超过限制:每条最多 4 张,整组最多 200 张。',
idempotencyConflict: '这次提交和之前的请求标识冲突,请刷新页面后重新提交。',
notReady: '任务还没有完成,完成后才能下载。',
outputDeleted: '这个任务的结果文件已经被清理,无法下载。',
@@ -2312,6 +2503,7 @@ function batchImageText(key: BatchImageTextKey) {
itemFailed: '这条明细没有成功图片,无法预览。',
itemImageIndexOutOfRange: '这条明细没有可预览的图片。',
downloadLimited: '当前下载请求太多,请稍后再试。',
downloadTooLarge: '这个 ZIP 太大,已超过单次下载限制。请减少单次下载数量,或联系管理员调整批量下载上限。',
deleteNotReady: '任务结束后才能删除记录。正在生成或结算中的任务请先等待完成。',
disabled: '批量生图功能当前未开启。',
authRequired: '当前 API Key 不可用或已失效,请重新选择密钥。',
@@ -2360,6 +2552,10 @@ function batchImageText(key: BatchImageTextKey) {
invalidItems: 'The prompt list is invalid. Check that it is not empty, within the item limit, and still using 1K image size.',
duplicateCustomId: 'Custom IDs in the prompt list must be unique.',
promptTooLong: 'One prompt is too long. Shorten it and try again.',
invalidReferenceImage: 'A reference image is invalid. Use PNG, JPEG, or WebP under 10 MB.',
tooManyReferenceImages: 'Too many reference images. Flash Image allows up to 3 per item, Pro Image allows up to 14, and each job allows up to 1000 total.',
referenceImagesTooLarge: 'Reference images are too large. Inline reference images are limited to 128 MB per job; use gs:// file_uri or split the job for large batches.',
tooManyOutputImages: 'Too many expected output images. Each prompt can request up to 4 images, and each job can generate up to 200 images.',
idempotencyConflict: 'This submission conflicts with a previous request ID. Refresh the page and submit again.',
notReady: 'The job is not complete yet. Download will be available after completion.',
outputDeleted: 'The result files for this job have already been cleaned up.',
@@ -2367,6 +2563,7 @@ function batchImageText(key: BatchImageTextKey) {
itemFailed: 'This item has no successful image to preview.',
itemImageIndexOutOfRange: 'This item has no previewable image.',
downloadLimited: 'Too many download requests are active. Please try again later.',
downloadTooLarge: 'This ZIP is too large for a single download. Download fewer items at once or ask an administrator to raise the batch download limit.',
deleteNotReady: 'Job records can only be deleted after the job finishes.',
disabled: 'Batch image generation is currently disabled.',
authRequired: 'The current API key is unavailable or expired. Select the key again.',
@@ -2446,6 +2643,18 @@ function batchImageErrorMessage(error: any, fallback: string) {
if (code === 'BATCH_IMAGE_PROMPT_TOO_LONG') {
return batchImageText('promptTooLong')
}
if (code === 'BATCH_IMAGE_INVALID_REFERENCE_IMAGE') {
return batchImageText('invalidReferenceImage')
}
if (code === 'BATCH_IMAGE_TOO_MANY_REFERENCE_IMAGES') {
return batchImageText('tooManyReferenceImages')
}
if (code === 'BATCH_IMAGE_REFERENCE_IMAGES_TOO_LARGE') {
return batchImageText('referenceImagesTooLarge')
}
if (code === 'BATCH_IMAGE_TOO_MANY_OUTPUT_IMAGES') {
return batchImageText('tooManyOutputImages')
}
if (code === 'BATCH_IMAGE_IDEMPOTENCY_CONFLICT') {
return batchImagePlainError(batchImageText('idempotencyConflict'))
}
@@ -2467,6 +2676,9 @@ function batchImageErrorMessage(error: any, fallback: string) {
if (code === 'BATCH_IMAGE_DOWNLOAD_LIMITED') {
return batchImageText('downloadLimited')
}
if (code === 'BATCH_IMAGE_DOWNLOAD_TOO_LARGE') {
return batchImageText('downloadTooLarge')
}
if (code === 'BATCH_IMAGE_RECORD_DELETE_NOT_READY') {
return batchImagePlainError(batchImageText('deleteNotReady'))
}
@@ -2514,6 +2726,20 @@ watch(
},
)
watch(
() => form.model,
() => {
const limit = selectedModelReferenceLimit.value
if (limit <= 0) {
referenceImageDrafts.value = []
return
}
if (referenceImageDrafts.value.length > limit) {
referenceImageDrafts.value = referenceImageDrafts.value.slice(0, limit)
}
},
)
onBeforeUnmount(() => {
stopPolling()
if (previewCacheCleanupTimer) {
@@ -2560,4 +2786,14 @@ onBeforeUnmount(() => {
.batch-prompt-popover p {
scrollbar-width: thin;
}
.batch-output-count-select {
height: 36px;
min-height: 36px;
padding-top: 0;
padding-bottom: 0;
padding-left: 14px;
padding-right: 34px;
line-height: 36px;
}
</style>
@@ -58,5 +58,34 @@ Claude model selection:
## Codex Follow-Up Note
Codex spot-checked the first P2 after Claude's report. `enqueueBillingRetry` exists in `batch_image_public.go`, but no obvious max retry or terminal handoff was found in the quick search. Keep this as an open risk for the next implementation/test pass rather than treating it as resolved.
Codex spot-checked the first P2 after Claude's report. The current implementation has a bounded settlement billing retry path:
- `batch_image_settlement.go` defines `batchImageSettlementMaxRetries = 5`.
- Repeated `SETTLEMENT_BILLING_FAILED` increments job retry state.
- Once the retry limit is reached, settlement fails the job and releases the remaining hold through the idempotent release path.
- `batch_image_settlement_test.go` covers transient settlement requeue, retry exhaustion release, and idempotent release after transition failure.
So Claude's original "unbounded settlement retry" risk should be treated as resolved in the current PR state, not as an open blocker.
## 2026-07-07 Follow-Up Addendum
Claude Code was later used in a bounded pass to update the QA test-case matrix with the online verification scenarios. Codex performed the online API/database checks and fed the verified facts back into the report; this addendum does not claim Claude personally executed the paid online image runs.
Additional scenarios now recorded in `test-case.md`:
- `BI-ONLINE-001`: one-image success settlement balance closure.
- `BI-ONLINE-002`: immediate cancel after submit releases hold and charges zero.
- `BI-ONLINE-003`: Gemini API-key provider path is selectable/callable; the test key had no prepayment, so successful generation was not continued; failed submit released hold and charged zero.
- `BI-ONLINE-004`: two-item partial failure charged only the one successful image and included the failed item in `errors.json`.
Current PR readiness view after follow-up:
- `GO behind flag`: acceptable for upstream review and merge discussion while `BATCH_IMAGE_ENABLED` and `allow_batch_image_generation` remain opt-in.
- `Not GA by default`: do not enable for all groups until operators have monitored real traffic and provider/account configuration.
- Amount-sensitive paths now have online evidence for success, cancel, partial failure, failed submit release, and `frozen_balance` returning to zero.
Remaining non-blocking gaps:
- No high-concurrency online stress test was run because it would create unnecessary provider cost and operational pressure.
- API-key upstream path was not proven with a successful paid image because the available test key had no prepayment.
- A future integration test can still exercise simultaneous cancel vs settlement under load, even though Redis per-job locks, database row locks, and billing request idempotency are already present.
@@ -80,9 +80,10 @@ Quick action origin:
## Residual Risks
- Real provider failure combinations should still be tested with controlled fake/fixture provider outputs: malformed output JSONL, missing image bytes, provider cancelled after partial success, and delayed output indexing.
- Concurrent cancel vs settlement needs a dedicated integration test with simultaneous requests to prove row-lock behavior under load, not only unit/static coverage.
- Settlement billing failure retry currently needs a clearer bounded retry or operator handoff story; Claude independently flagged this too.
- Concurrent cancel vs settlement still benefits from a dedicated integration test with simultaneous requests to prove row-lock behavior under load, not only unit/static coverage.
- Google/Gemini API-key upstream success was not run because the available test key had no prepayment. The provider was verified as selectable/callable, and failed submit released hold.
- Online high-concurrency stress was intentionally skipped to avoid unnecessary provider cost; Redis per-job locks, database row locks, and billing request idempotency cover the core correctness path in code.
## Recommendation
Proceed to broader review with Claude and/or manual exploratory testing. Before production enablement, add one integration test for cancel/settle concurrency and one for persistent settlement billing failure recovery.
Proceed to upstream review behind `BATCH_IMAGE_ENABLED` and `allow_batch_image_generation`. Before broad GA, add or run a dedicated cancel/settle concurrency integration test and a paid one-image API-key upstream success test with a properly prepaid Google key.
@@ -0,0 +1,55 @@
# PR Description Draft: Batch Image Generation MVP
## Summary
This PR adds an opt-in batch image generation MVP for Gemini image models through Sub2API.
Main capabilities:
- Public async batch image API under `/v1/images/batches*`.
- Provider support for Vertex-managed Gemini batch jobs and Gemini API batch jobs.
- Redis-backed worker queue, delayed requeue, stale active recovery, and per-job locks.
- PostgreSQL job/item state, provider refs kept internal, and proxied item/ZIP downloads.
- Balance hold, capture, release, partial-failure settlement, and idempotent billing request ids.
- Frontend user batch image guide and gated navigation entry.
- Feature gates through global `BATCH_IMAGE_ENABLED` and group-level `allow_batch_image_generation`.
The feature is intentionally not GA by default. It should be enabled first through feature flag and group opt-in only.
## Docs Included
- `docs/BATCH_IMAGE_MVP.md`: API, lifecycle, billing, provider notes, config, official Google enablement, and operations checklist.
- `test-reports/batch-image-20260706-codex/test-case.md`: QA case matrix.
- `test-reports/batch-image-20260706-codex/codex-report.md`: Codex test report.
- `test-reports/batch-image-20260706-codex/claude-report.md`: Claude Code review report plus 2026-07-07 follow-up addendum.
- `test-reports/batch-image-20260706-codex/smoke-summary.txt`: local HTTP smoke result.
## Validation
Automated/local validation recorded in the test reports:
- Backend batch image service/repository/server tests: pass.
- Frontend typecheck/build/full tests: pass.
- Local HTTP smoke: unauthenticated access, model listing, insufficient balance, completed status/items/download, and balance restoration.
- Settlement tests cover successful-image-only charging, zero-success completion, already-settled idempotency, billing crash idempotency, cost-over-hold rejection, pricing snapshot, bounded settlement retry, retry exhaustion release, and billing request ids.
Online validation recorded on 2026-07-07:
- One-image Vertex success: hold `0.0804`, actual `0.0737`, release `0.0067`, final `frozen_balance=0`.
- Immediate cancel after submit: hold released, charged `0`, no capture usage log.
- Two-item partial failure: one success, one failure, charged one image only, `errors.json` contains failed item, final `frozen_balance=0`.
- Gemini API-key provider path: provider selectable/callable; test key had no prepayment, so successful generation was not continued; failed submit released hold and charged `0`.
## Remaining Non-Blocking Gaps
- No high-concurrency online stress test was run because it would create unnecessary provider cost and production pressure.
- Gemini API-key upstream success still needs one paid/prepaid low-cost image test when such a key is available.
- A future integration test can exercise simultaneous cancel vs settlement under load, although Redis per-job locks, PostgreSQL row locks, and billing idempotency are already present.
## Rollout Recommendation
Merge/review behind flags only:
- Keep `BATCH_IMAGE_ENABLED=false` by default.
- Enable only for selected Gemini groups through `allow_batch_image_generation=true`.
- Start with one controlled group and monitor job state, provider errors, hold/capture/release events, and download volume before broader enablement.
@@ -14,6 +14,7 @@ Validate the Sub2API batch image feature before broader external review:
- Billing hold, release, capture, settlement, and recovery unit coverage
- Frontend batch image page type/build/test health
- Agent-copy instruction text for slower polling and resume records
- PR docs/readiness materials for upstream review
## Test Data
@@ -38,4 +39,9 @@ Validate the Sub2API batch image feature before broader external review:
| BI-FE-001 | Frontend typecheck/build | Pass |
| BI-FE-002 | Full frontend test suite | Pass |
| BI-FE-003 | Batch image guide copy text | Includes slower polling and local resume-record requirements |
| BI-ONLINE-001 | One-image success settlement balance closure | Hold `0.0804`, actual `0.0737`, release `0.0067`; `frozen_balance` returns `0` |
| BI-ONLINE-002 | Immediate cancel after submit | Hold released, charged `0` |
| BI-ONLINE-003 | Google/Gemini API-key provider path | Account selectable/callable, models list returns `provider=gemini_api`; test key has no prepayment so no successful generation attempted; submit failure released hold, charged `0` |
| BI-ONLINE-004 | Two-item partial failure | One item succeeded, one item failed; charged one image only, `errors.json` contains failed item, `frozen_balance` returns `0` |
| BI-DOC-001 | Batch image MVP feature doc | Includes API surface, lifecycle, billing, provider notes, config, official Google enablement, and PR hygiene |
| BI-DOC-002 | PR description draft | Summarizes feature scope, tests, feature flags, and remaining non-blocking gaps for upstream review |