mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 13:18:18 +08:00
docs: add batch image PR readiness notes
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -2394,7 +2394,7 @@ export default {
|
||||
imageMultiplier: '生图独立倍率',
|
||||
batchDiscountMultiplier: '批量生图折扣倍率',
|
||||
batchHoldMultiplier: '批量冻结价格比例',
|
||||
batchSectionHint: '批量生图仅影响批量任务:结算价格会叠加批量折扣倍率,提交时冻结金额按普通生图原价 × 批量冻结价格比例计算。',
|
||||
batchSectionHint: '批量生图仅影响批量任务:结算价格会叠加批量折扣倍率,提交时冻结金额按普通生图原价 × 批量冻结价格比例计算。参考图也会产生上游输入 token 消耗,建议批量生图折扣倍率设置大于 0.5。',
|
||||
batchDisabledHint: '请先开启当前分组生图,才能开启批量生图。',
|
||||
modeHint: '默认关闭独立倍率时,图片费用 = 图片价格 × 当前分组有效倍率;开启独立倍率后,图片费用 = 图片价格 × 生图独立倍率。',
|
||||
finalPricePreview: '最终单张价格预览',
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user