diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 435164154f..8c67ae987e 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -1912,7 +1912,7 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn } allowImageGeneration := input.AllowImageGeneration || defaultAllowImageGenerationForPlatform(platform) - allowBatchImageGeneration := input.AllowBatchImageGeneration && allowImageGeneration + allowBatchImageGeneration := input.AllowBatchImageGeneration && allowImageGeneration && platform == PlatformGemini // 如果指定了复制账号的源分组,先获取账号 ID 列表 var accountIDsToCopy []int64 @@ -2150,7 +2150,7 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd if input.AllowBatchImageGeneration != nil { group.AllowBatchImageGeneration = *input.AllowBatchImageGeneration } - if !group.AllowImageGeneration { + if !group.AllowImageGeneration || group.Platform != PlatformGemini { group.AllowBatchImageGeneration = false } if input.ImageRateIndependent != nil { diff --git a/backend/internal/service/admin_service_group_test.go b/backend/internal/service/admin_service_group_test.go index 52485debff..0c229c389e 100644 --- a/backend/internal/service/admin_service_group_test.go +++ b/backend/internal/service/admin_service_group_test.go @@ -252,6 +252,26 @@ func TestAdminService_CreateGroup_DisablesBatchImageWhenImageGenerationDisabled( require.False(t, group.AllowBatchImageGeneration) } +func TestAdminService_CreateGroup_DisablesBatchImageForNonGeminiPlatform(t *testing.T) { + repo := &groupRepoStubForAdmin{} + svc := &adminServiceImpl{groupRepo: repo} + + group, err := svc.CreateGroup(context.Background(), &CreateGroupInput{ + Name: "openai-image", + Description: "OpenAI image group", + Platform: PlatformOpenAI, + RateMultiplier: 1.0, + AllowImageGeneration: true, + AllowBatchImageGeneration: true, + }) + require.NoError(t, err) + require.NotNil(t, group) + require.NotNil(t, repo.created) + require.True(t, repo.created.AllowImageGeneration) + require.False(t, repo.created.AllowBatchImageGeneration) + require.False(t, group.AllowBatchImageGeneration) +} + // TestAdminService_UpdateGroup_WithImagePricing 测试更新分组时 ImagePrice 字段正确更新 func TestAdminService_UpdateGroup_WithImagePricing(t *testing.T) { existingGroup := &Group{ @@ -370,6 +390,29 @@ func TestAdminService_UpdateGroup_DisablesBatchImageWhenImageGenerationDisabled( require.False(t, group.AllowBatchImageGeneration) } +func TestAdminService_UpdateGroup_DisablesBatchImageWhenPlatformChangesFromGemini(t *testing.T) { + existingGroup := &Group{ + ID: 1, + Name: "existing-gemini", + Platform: PlatformGemini, + Status: StatusActive, + AllowImageGeneration: true, + AllowBatchImageGeneration: true, + } + repo := &groupRepoStubForAdmin{getByID: existingGroup} + svc := &adminServiceImpl{groupRepo: repo} + + group, err := svc.UpdateGroup(context.Background(), 1, &UpdateGroupInput{ + Platform: PlatformOpenAI, + }) + require.NoError(t, err) + require.NotNil(t, group) + require.NotNil(t, repo.updated) + require.Equal(t, PlatformOpenAI, repo.updated.Platform) + require.False(t, repo.updated.AllowBatchImageGeneration) + require.False(t, group.AllowBatchImageGeneration) +} + func TestAdminService_UpdateGroup_ClearsDescriptionWhenEmptyString(t *testing.T) { existingGroup := &Group{ ID: 1, diff --git a/backend/internal/service/batch_image_public.go b/backend/internal/service/batch_image_public.go index ad838dac8c..cffbab609d 100644 --- a/backend/internal/service/batch_image_public.go +++ b/backend/internal/service/batch_image_public.go @@ -872,6 +872,9 @@ func (s *BatchImagePublicService) ensureGroupAllowsBatchImage(ctx context.Contex if !group.AllowBatchImageGeneration { return ErrBatchImageGroupDisabled } + if group.Platform != PlatformGemini { + return ErrBatchImageGroupDisabled + } return nil } diff --git a/backend/internal/service/batch_image_public_test.go b/backend/internal/service/batch_image_public_test.go index 4060c7de1b..d59784269f 100644 --- a/backend/internal/service/batch_image_public_test.go +++ b/backend/internal/service/batch_image_public_test.go @@ -72,7 +72,9 @@ func TestBatchImagePublicService_Submit(t *testing.T) { svc.GroupRepo = &publicBatchImageGroupRepo{groups: map[int64]*Group{ groupID: { ID: groupID, + Platform: PlatformGemini, RateMultiplier: 2.0, + AllowImageGeneration: true, AllowBatchImageGeneration: true, ImageRateIndependent: false, BatchImageDiscountMultiplier: 0.8, @@ -104,7 +106,9 @@ func TestBatchImagePublicService_Submit(t *testing.T) { svc.GroupRepo = &publicBatchImageGroupRepo{groups: map[int64]*Group{ groupID: { ID: groupID, + Platform: PlatformGemini, RateMultiplier: 1.0, + AllowImageGeneration: true, AllowBatchImageGeneration: true, ImagePrice1K: &imagePrice, BatchImageDiscountMultiplier: 0.5, @@ -140,6 +144,7 @@ func TestBatchImagePublicService_Submit(t *testing.T) { svc.GroupRepo = &publicBatchImageGroupRepo{groups: map[int64]*Group{ groupID: { ID: groupID, + Platform: PlatformGemini, RateMultiplier: 1, AllowBatchImageGeneration: false, BatchImageDiscountMultiplier: 0.5, @@ -472,7 +477,9 @@ func TestBatchImagePublicService_ListModels(t *testing.T) { svc.GroupRepo = &publicBatchImageGroupRepo{groups: map[int64]*Group{ groupID: { ID: groupID, + Platform: PlatformGemini, RateMultiplier: 1, + AllowImageGeneration: true, AllowBatchImageGeneration: true, BatchImageDiscountMultiplier: 0.5, BatchImageHoldMultiplier: 0.6, diff --git a/docs/BATCH_IMAGE_MVP.md b/docs/BATCH_IMAGE_MVP.md index 2ca2dc00f8..69b0be724a 100644 --- a/docs/BATCH_IMAGE_MVP.md +++ b/docs/BATCH_IMAGE_MVP.md @@ -7,7 +7,7 @@ Supported providers: - `gemini_api` - `vertex` -API users do not see Gemini file names, Vertex job names, GCS paths, signed URLs, API keys, or service account material. Downloads are proxied through Sub2API. +API users do not see Gemini file names, Vertex job names, GCS paths, signed URLs, API keys, or service account material. Downloads are proxied through Sub2API in this MVP. ## API Routes @@ -198,6 +198,7 @@ For the managed Vertex/GCS batch bucket, disable Cloud Storage soft delete or co `gemini_api`: - Uses Gemini Batch API with JSONL file mode. +- Supports Gemini `apikey` upstream accounts with a configured API key. - 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. @@ -205,11 +206,14 @@ For the managed Vertex/GCS batch bucket, disable Cloud Storage soft delete or co `vertex`: - Uses Vertex `BatchPredictionJob` with managed GCS JSONL. +- Supports Gemini `service_account` upstream accounts with valid service account JSON. - GCS bucket and prefix are server-managed. - Vertex job name and GCS paths are internal. - Batch image output should be treated as `1K`/default only in MVP. - Do not promise `2K` or `4K`. +Other Gemini account/login types are not selected by the current batch image providers unless they expose equivalent API-key or service-account credentials through the same provider flow. They were not covered by the 2026-07-07 PR validation. + ## 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. @@ -221,7 +225,7 @@ Recommended production path: - 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. +- Enable `BATCH_IMAGE_ENABLED` globally, enable image generation on the intended Gemini group, then enable `allow_batch_image_generation` for that group. Non-Gemini groups are not eligible for batch image generation, and the admin UI only shows the batch image group switch after image generation is enabled on a Gemini group. API-key path: @@ -312,6 +316,10 @@ Feature flags default to disabled. - Confirm billing pricing. - Run smoke tests before enabling. +## Future Optimization + +- Optional object-storage download offload: persist completed image outputs to an operator-configured object store such as GCS, S3, or R2, then issue short-lived signed download links to users. This would avoid routing large image/ZIP downloads through the Sub2API server, which is useful for small-bandwidth deployments. Keep it opt-in because it needs extra storage credentials, lifecycle cleanup, signed-URL expiry policy, access auditing, and compatibility with output deletion. + ## Security Checklist - No provider refs in public responses. diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 372dedbb38..3ef97ec998 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -2314,6 +2314,7 @@ export default { 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. 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.', + batchGeminiOnlyHint: 'Batch image generation is currently available only for Gemini groups.', 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', notConfigured: 'Not configured' diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index e3fa897c86..2e6c41f92f 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -2396,6 +2396,7 @@ export default { batchHoldMultiplier: '批量冻结价格比例', batchSectionHint: '批量生图仅影响批量任务:结算价格会叠加批量折扣倍率,提交时冻结金额按普通生图原价 × 批量冻结价格比例计算。参考图也会产生上游输入 token 消耗,建议批量生图折扣倍率设置大于 0.5。', batchDisabledHint: '请先开启当前分组生图,才能开启批量生图。', + batchGeminiOnlyHint: '批量生图当前仅支持 Gemini 分组。', modeHint: '默认关闭独立倍率时,图片费用 = 图片价格 × 当前分组有效倍率;开启独立倍率后,图片费用 = 图片价格 × 生图独立倍率。', finalPricePreview: '最终单张价格预览', notConfigured: '未配置' diff --git a/frontend/src/views/admin/GroupsView.vue b/frontend/src/views/admin/GroupsView.vue index 4b82185270..436749ab3b 100644 --- a/frontend/src/views/admin/GroupsView.vue +++ b/frontend/src/views/admin/GroupsView.vue @@ -889,25 +889,17 @@ -
+
-

- {{ t("admin.groups.imagePricing.batchDisabledHint") }} -

{{ t("admin.groups.imagePricing.batchSectionHint") }}

@@ -943,6 +935,12 @@
+

+ {{ t("admin.groups.imagePricing.batchGeminiOnlyHint") }} +

@@ -2282,25 +2280,17 @@ -
+
-

- {{ t("admin.groups.imagePricing.batchDisabledHint") }} -

{{ t("admin.groups.imagePricing.batchSectionHint") }}

@@ -2336,6 +2326,12 @@
+

+ {{ t("admin.groups.imagePricing.batchGeminiOnlyHint") }} +

@@ -4036,6 +4032,7 @@ const editForm = reactive({ }); type ImagePricingFormState = { + platform: GroupPlatform; allow_image_generation: boolean; allow_batch_image_generation: boolean; rate_multiplier: number; @@ -4103,10 +4100,10 @@ const editImageFinalPricePreview = computed(() => const resetDisabledBatchImagePricing = ( form: Pick< ImagePricingFormState, - "allow_image_generation" | "allow_batch_image_generation" | "batch_image_discount_multiplier" | "batch_image_hold_multiplier" + "platform" | "allow_image_generation" | "allow_batch_image_generation" | "batch_image_discount_multiplier" | "batch_image_hold_multiplier" >, ) => { - if (!form.allow_image_generation) { + if (form.platform !== "gemini" || !form.allow_image_generation) { form.allow_batch_image_generation = false; } if (!form.allow_batch_image_generation) { @@ -4683,6 +4680,7 @@ watch( createForm.require_oauth_only = false; createForm.require_privacy_set = false; } + resetDisabledBatchImagePricing(createForm); resetModelsListState(createModelsListState); loadModelsListCandidates("create", 0, newVal); }, @@ -4715,6 +4713,7 @@ watch( editForm.require_oauth_only = false; editForm.require_privacy_set = false; } + resetDisabledBatchImagePricing(editForm); if (editingGroup.value) { resetModelsListState(editModelsListState, editForm.platform === editingGroup.value.platform ? editingGroup.value.models_list_config : undefined); loadModelsListCandidates("edit", editingGroup.value.id, newVal); diff --git a/test-reports/batch-image-20260706-codex/codex-report.md b/test-reports/batch-image-20260706-codex/codex-report.md index 6cd33b45b6..3497f2d994 100644 --- a/test-reports/batch-image-20260706-codex/codex-report.md +++ b/test-reports/batch-image-20260706-codex/codex-report.md @@ -60,7 +60,7 @@ Covered by automated tests and smoke: The batch image feature has two independent gates: - Global runtime gate: `BATCH_IMAGE_ENABLED` controls whether `/v1/images/batches*` is available at all. If disabled, the backend returns `404 BATCH_IMAGE_DISABLED` regardless of group settings. This value is loaded at application startup, so changing the server environment requires restarting/redeploying the app container. -- Group/API-key gate: `groups.allow_batch_image_generation` controls whether a user's API key may use the feature. If the global gate is enabled but the API key's group is not allowed, the backend returns `403 BATCH_IMAGE_GROUP_DISABLED`. +- Group/API-key gate: only Gemini groups with image generation enabled can enable `groups.allow_batch_image_generation`, which controls whether a user's API key may use the feature. If the global gate is enabled but the API key's group is not allowed, the backend returns `403 BATCH_IMAGE_GROUP_DISABLED`. Frontend visibility follows the same group/API-key gate for user-facing entry points: diff --git a/test-reports/batch-image-20260706-codex/pr-description.md b/test-reports/batch-image-20260706-codex/pr-description.md index a490237b6c..2732109e43 100644 --- a/test-reports/batch-image-20260706-codex/pr-description.md +++ b/test-reports/batch-image-20260706-codex/pr-description.md @@ -8,11 +8,12 @@ Main capabilities: - Public async batch image API under `/v1/images/batches*`. - Provider support for Vertex-managed Gemini batch jobs and Gemini API batch jobs. +- Upstream account support is limited to Gemini `service_account` accounts for the Vertex provider and Gemini `apikey` accounts for the Gemini API provider. - 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`. +- Feature gates through global `BATCH_IMAGE_ENABLED`, Gemini-only group eligibility, image-generation enablement, 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. @@ -44,12 +45,14 @@ Online validation recorded on 2026-07-07: - 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. +- Other Gemini login/account types were not tested and are not selected by the current providers unless they can expose equivalent service-account or API-key credentials through the same provider flow. - 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. +- Optional object-storage download offload could be added later: store completed outputs in GCS/S3/R2 and issue short-lived signed links so large image/ZIP downloads do not consume Sub2API server bandwidth. This should remain opt-in because it adds storage credentials, lifecycle cleanup, signed-link expiry, and access-audit requirements. ## 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`. +- Enable only for selected Gemini groups after `allow_image_generation=true`, then set `allow_batch_image_generation=true`; non-Gemini groups are intentionally not eligible for this switch. - Start with one controlled group and monitor job state, provider errors, hold/capture/release events, and download volume before broader enablement.