mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-26 16:03:35 +00: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()
|
||||
|
||||
Reference in New Issue
Block a user