fix: restrict batch image groups to gemini

This commit is contained in:
Turtle_Li
2026-07-07 04:14:09 +08:00
parent 3c43fdec11
commit 89edba8028
10 changed files with 92 additions and 27 deletions
+2 -2
View File
@@ -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 {
@@ -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,
@@ -872,6 +872,9 @@ func (s *BatchImagePublicService) ensureGroupAllowsBatchImage(ctx context.Contex
if !group.AllowBatchImageGeneration {
return ErrBatchImageGroupDisabled
}
if group.Platform != PlatformGemini {
return ErrBatchImageGroupDisabled
}
return nil
}
@@ -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,
+10 -2
View File
@@ -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.
+1
View File
@@ -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'
+1
View File
@@ -2396,6 +2396,7 @@ export default {
batchHoldMultiplier: '批量冻结价格比例',
batchSectionHint: '批量生图仅影响批量任务:结算价格会叠加批量折扣倍率,提交时冻结金额按普通生图原价 × 批量冻结价格比例计算。参考图也会产生上游输入 token 消耗,建议批量生图折扣倍率设置大于 0.5。',
batchDisabledHint: '请先开启当前分组生图,才能开启批量生图。',
batchGeminiOnlyHint: '批量生图当前仅支持 Gemini 分组。',
modeHint: '默认关闭独立倍率时,图片费用 = 图片价格 × 当前分组有效倍率;开启独立倍率后,图片费用 = 图片价格 × 生图独立倍率。',
finalPricePreview: '最终单张价格预览',
notConfigured: '未配置'
+19 -20
View File
@@ -889,25 +889,17 @@
</div>
</div>
</div>
<div class="mt-4 border-t border-dashed border-gray-200 pt-4 dark:border-dark-700">
<div v-if="createForm.platform === 'gemini' && createForm.allow_image_generation" class="mt-4 border-t border-dashed border-gray-200 pt-4 dark:border-dark-700">
<label
class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300"
:class="!createForm.allow_image_generation ? 'cursor-not-allowed opacity-50' : ''"
>
<input
v-model="createForm.allow_batch_image_generation"
type="checkbox"
:disabled="!createForm.allow_image_generation"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
{{ t("admin.groups.imagePricing.allowBatchImageGeneration") }}
</label>
<p
v-if="!createForm.allow_image_generation"
class="mt-2 text-xs text-gray-500 dark:text-gray-400"
>
{{ t("admin.groups.imagePricing.batchDisabledHint") }}
</p>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.groups.imagePricing.batchSectionHint") }}
</p>
@@ -943,6 +935,12 @@
</div>
</div>
</div>
<p
v-else-if="createForm.platform !== 'gemini'"
class="mt-4 border-t border-dashed border-gray-200 pt-4 text-xs text-gray-500 dark:border-dark-700 dark:text-gray-400"
>
{{ t("admin.groups.imagePricing.batchGeminiOnlyHint") }}
</p>
</div>
<!-- 高峰时段倍率配置(仅订阅类型分组) -->
@@ -2282,25 +2280,17 @@
</div>
</div>
</div>
<div class="mt-4 border-t border-dashed border-gray-200 pt-4 dark:border-dark-700">
<div v-if="editForm.platform === 'gemini' && editForm.allow_image_generation" class="mt-4 border-t border-dashed border-gray-200 pt-4 dark:border-dark-700">
<label
class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300"
:class="!editForm.allow_image_generation ? 'cursor-not-allowed opacity-50' : ''"
>
<input
v-model="editForm.allow_batch_image_generation"
type="checkbox"
:disabled="!editForm.allow_image_generation"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
{{ t("admin.groups.imagePricing.allowBatchImageGeneration") }}
</label>
<p
v-if="!editForm.allow_image_generation"
class="mt-2 text-xs text-gray-500 dark:text-gray-400"
>
{{ t("admin.groups.imagePricing.batchDisabledHint") }}
</p>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.groups.imagePricing.batchSectionHint") }}
</p>
@@ -2336,6 +2326,12 @@
</div>
</div>
</div>
<p
v-else-if="editForm.platform !== 'gemini'"
class="mt-4 border-t border-dashed border-gray-200 pt-4 text-xs text-gray-500 dark:border-dark-700 dark:text-gray-400"
>
{{ t("admin.groups.imagePricing.batchGeminiOnlyHint") }}
</p>
</div>
<!-- 高峰时段倍率配置(仅订阅类型分组) -->
@@ -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);
@@ -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:
@@ -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.