feat: add OpenAI image generation controls

This commit is contained in:
2ue
2026-05-05 03:26:54 +08:00
parent 4de28fec8c
commit 6faa344916
85 changed files with 6086 additions and 568 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-29
@@ -0,0 +1,227 @@
## Context
当前代码已经具备图片价格字段和部分图片转发能力,但边界不完整:
- `backend/ent/schema/group.go` 只有 `rate_multiplier``image_price_1k/2k/4k`,没有分组级生图能力开关,也没有“图片是否共享分组倍率”的开关。
- `backend/internal/handler/openai_images.go` 在解析 `/v1/images/*` 后只做通用余额/订阅资格检查,没有检查分组是否允许生图。
- `backend/internal/service/openai_gateway_service.go` 对 Codex CLI 会自动注入 `image_generation` tool;通用 `/v1/responses` 只记录日志,没有把图片工具产物数量写入 `OpenAIForwardResult.ImageCount`
- `backend/internal/service/billing_service.go``CalculateImageCost` 当前使用 `image_price_* * image_count * rate_multiplier`。这个行为本身可以作为默认兼容模式,但普通编码分组 `rate_multiplier=0.15` 且希望图片最终价为 `0.2/张` 时,管理员必须填写 `image_price=0.2/0.15`,不可读且不适合长期运营。
- `backend/internal/service/openai_gateway_service.go``backend/internal/service/gateway_service.go` 的渠道图片计费路径当前传 `RequestCount: 1`,多图请求会按 1 次收费。
- `backend/internal/service/openai_images.go` 的 OpenAI 图片尺寸分层此前只覆盖少量固定尺寸;`gpt-image-2` 官方文档已经支持满足约束的自定义 `size`,因此本地计费必须能够对未知尺寸做稳定分档,同时不能因为本地映射不认识就提前拦截请求。
用户澄清后的业务要求是:普通编码分组可以关闭生图,也可以开启生图;开启后默认继续共享现有分组倍率以保持兼容,但管理员可以打开“生图倍率独立”开关,改用单独的图片倍率输入框。图片分组是推荐的运营隔离方式,但不是唯一承载方式。
## Goals / Non-Goals
**Goals:**
- 分组具备明确的 `allow_image_generation` 开关,所有已知生图入口在调度上游前执行同一个权限判断。
- 分组具备“生图倍率是否独立”的开关;默认 `false`,即共享当前代码里的有效分组倍率。
- 生图倍率独立开关打开后,图片费用使用单独的 `image_rate_multiplier`,不再使用普通编码分组的倍率。
- 保留现有 `image_price_1k/2k/4k` 字段作为图片单价配置,不强制把它们迁移成新的语义。
- 普通编码分组在 `allow_image_generation=false` 时仍可正常使用 `gpt-5.4` / `gpt-5.5` 文本能力,但不能使用图片工具。
- 普通编码分组在 `allow_image_generation=true` 时可使用 `gpt-5.4` / `gpt-5.5 + image_generation`,且按实际图片数量收费。
- 通用 `/v1/responses`、OpenAI Images API、流式、非流式、透传路径全部把成功产出的图片数量写入 `ImageCount`
- 渠道 `billing_mode=image` 使用真实 `ImageCount`,不再固定按 1 次收费。
**Non-Goals:**
- 不引入新的第三方依赖。
- 不改变 OpenAI 上游协议;只在现有请求转发、响应解析和计费归因层补齐控制。
- 不把“图片分组”做成唯一安全边界;分组开关和图片计费逻辑必须适用于任意开启生图的分组。
- 不在本变更中实现预扣费/资金冻结;失败请求仍不收费,成功请求按实际产物后扣费。
- 不改变默认历史图片价格行为;默认共享现有有效倍率,历史 `图片价格 * 分组/用户有效倍率` 的扣费行为保持。
- 不在本变更中新增用户级图片独立倍率覆盖;用户专属普通倍率只在共享倍率模式下继续影响图片。
## Decisions
### 0. 兼容性优先原则
本变更的默认行为必须以“不改变现有已配置分组的最终扣费”为优先级:
- 迁移不修改现有 `image_price_1k/2k/4k`
- 迁移把所有现有分组设置为 `image_rate_independent=false`,因此现有图片路径继续使用当前有效分组倍率。
- 管理员不传新字段更新分组时,不得覆盖已保存的 `allow_image_generation``image_rate_independent``image_rate_multiplier`
- 前端编辑旧分组时必须回显服务端值;不能因为表单默认值把旧分组从共享倍率误改成独立倍率,或把允许生图误改成禁止生图。
- 只有管理员显式打开 `image_rate_independent` 后,图片扣费才从共享倍率切换到图片独立倍率。
### 1. 分组字段与迁移策略
新增三个分组字段,对应“2 个开关 + 1 个输入框”:
- `allow_image_generation BOOLEAN NOT NULL DEFAULT false`
- `image_rate_independent BOOLEAN NOT NULL DEFAULT false`
- `image_rate_multiplier DECIMAL(10,4) NOT NULL DEFAULT 1.0`
字段语义:
- `allow_image_generation`:是否支持当前分组生图。
- `image_rate_independent=false`:图片计费共享当前普通计费链路里的有效倍率,即当前 `userGroupRateResolver.Resolve(ctx, user.ID, groupID, group.RateMultiplier)` 得到的倍率;这保持现有行为。
- `image_rate_independent=true`:图片计费使用 `group.image_rate_multiplier`;普通编码的 `rate_multiplier` 和用户专属普通倍率不参与图片扣费。
- `image_price_1k/2k/4k`:继续表示图片基础单价,由选中的图片倍率模式继续相乘。
新建分组默认 `allow_image_generation=false`,避免新普通编码分组意外获得生图能力。为避免升级后立即打断已有图片业务,迁移对现有 `openai``gemini``antigravity` 分组回填 `allow_image_generation=true``anthropic` 分组保持 `false`。该回填只是兼容现状;上线后管理员必须按业务策略关闭不允许生图的普通编码分组。
迁移不改写已有 `image_price_1k/2k/4k`,并将所有现有分组设为 `image_rate_independent=false``image_rate_multiplier=1`。这样现有最终扣费公式保持不变:
```text
历史/默认模式图片最终扣费 = image_price_* * image_count * 当前有效分组倍率
```
普通编码分组 `rate_multiplier=0.15` 且希望图片 1K 最终扣费 `0.2/张` 时,管理员不再需要填写 `0.2/0.15`,而是设置:
```text
image_rate_independent = true
image_rate_multiplier = 1
image_price_1k = 0.2
```
如果希望图片也打折,例如图片标价 `0.2/张`、图片折扣 `0.8`,则设置:
```text
image_rate_independent = true
image_rate_multiplier = 0.8
image_price_1k = 0.2
```
### 2. 生图意图统一识别
新增一个服务层 helper,输入至少包含 endpoint、请求模型、请求体,输出是否为生图意图:
```text
isImageGenerationIntent =
endpoint 是 /v1/images/generations 或 /v1/images/edits
OR requested model 以 gpt-image- 开头
OR tools[] 存在 type == image_generation
OR tool_choice 显式指向 image_generation
```
生图意图判断必须在请求体被 Codex 注入、模型改写、渠道映射改写之前执行一次,并在这些改写之后再对最终请求体执行一次。原因是当前代码会在 `backend/internal/service/openai_gateway_service.go` 中注入 `image_generation` tool,也会在 `normalizeOpenAIResponsesImageOnlyModel` 中把 `gpt-image-*` 改写为文本模型 + 图片工具;只检查改写前或只检查改写后都可能漏掉场景。
`tool_choice` 判断只把明确指向 `image_generation` 的值视为生图意图;`auto``none``required` 本身不构成生图意图,但如果 `tools[]` 中存在 `image_generation`,仍由 `tools[]` 规则命中。
该判断必须在以下位置使用:
- `/v1/images/*` handler 解析请求后、账号调度前。
- `/v1/responses` 解析 body 后、Codex 自动注入 `image_generation` tool 前。
- `normalizeOpenAIResponsesImageOnlyModel``gpt-image-*` 改写为 Responses 文本模型前。
- OpenAI 高级 scheduler 入口保留现有账号能力检查,同时补齐渠道 restriction 检查,避免启用高级调度时绕过渠道模型限制。
`allow_image_generation=false` 时:
- 显式生图意图返回 HTTP 403,错误类型使用现有 `permission_error` 风格。
- Codex CLI 请求不自动注入 `image_generation` tool,也不追加图片桥接指令;如果请求没有显式生图意图,则继续按普通文本请求处理。
### 3. gpt-5.4 / gpt-5.5 生图承载方式
`gpt-5.4` / `gpt-5.5` 生图通过现有 OpenAI Responses API 的 `image_generation` tool 承载,不新增专用 endpoint
```json
{
"model": "gpt-5.4",
"input": "生成一张图片",
"tools": [
{
"type": "image_generation",
"model": "gpt-image-2",
"size": "1024x1024",
"output_format": "png"
}
],
"tool_choice": { "type": "image_generation" }
}
```
`model=gpt-image-*` 发到 `/v1/responses` 时保留现有改写方向:主模型改为 Responses 文本模型,图片模型放入 `image_generation` tool。计费时如果能从工具配置得到 `gpt-image-*`,图片默认价格按该图片模型解析;如果工具未指定图片模型,则使用当前转发结果的 billing model,并优先使用分组/渠道配置价格。
### 4. 图片数量归因
新增统一图片输出解析 helper,返回去重后的图片数量和可用图片元信息。必须覆盖以下已有或可借鉴的事件形态:
- 非流式 Responses JSON`output[]``type == image_generation_call``result` 非空。
- Responses SSE`response.output_item.done``item.type == image_generation_call``item.result` 非空。
- Responses SSE 完成事件:`response.completed.response.output[]` 中图片工具结果。
- Images API 非流式:顶层 `data[]`
- Images API 流式:顶层 `data[]``image_generation.completed``response.output_item.done``response.completed`
去重键按优先级使用 `item.id``call_id``result` 内容 hash。只统计最终图片,不统计 `partial_image`
`openaiStreamingResult` 增加 `imageCount``imageSize``imageBillingModel``handleStreamingResponse``handleStreamingResponsePassthrough``handleNonStreamingResponse``handleNonStreamingResponsePassthrough` 都必须把解析结果带回 `OpenAIForwardResult`。当 `ImageCount > 0` 时,即使上游 usage 为 0,也必须写 usage log 并进入图片计费。
### 5. 图片价格公式
图片计费先确定单价,再确定倍率:
```text
unit_price = 渠道 image 模式价格 或 分组 image_price_* 或 默认图片价格
image_multiplier =
如果 group.image_rate_independent == true: group.image_rate_multiplier
否则: 当前有效分组倍率
total_cost = unit_price * image_count
actual_cost = total_cost * image_multiplier
```
“当前有效分组倍率”必须沿用当前代码的倍率解析方式:默认配置倍率 → 分组 `rate_multiplier` → 用户专属分组倍率覆盖。这样 `image_rate_independent=false` 时完全保留当前行为。
`billing_mode=image` 的渠道价格是图片单价来源之一,仍优先于分组图片价格。图片渠道价格也必须按 `ImageCount` 计数,并使用同一套 `image_multiplier` 选择逻辑。
`billing_mode=per_request` 的非图片请求保持当前普通按次语义,继续使用普通 token 倍率;只有已经识别为图片请求且 `ImageCount > 0` 的路径使用图片计费逻辑。
`usage_logs.rate_multiplier` 继续表示“本次扣费实际使用的倍率”。因此:
- token 日志记录普通 token 有效倍率。
- image 日志在共享模式记录普通有效倍率。
- image 日志在独立模式记录 `image_rate_multiplier`
专用 `/v1/images/*` 仍按图片请求语义计费:当 `ImageCount > 0` 时,图片价格决定费用,伴随的上游 token usage 只记录不额外计 token 费用。这保持当前 Images API 的行为。
通用 `/v1/responses + image_generation` 的混合文本+图片输出存在一个明确取舍:如果继续沿用“`ImageCount > 0` 时只按图片计费”的当前计费分支,用户可以在一次图片请求中夹带大量文本输出而只付图片费用;如果改成“图片费用 + 非图片 token 费用”,会改变当前 `billing_mode=image` 的单一计费语义,并可能让渠道图片单价不再是全包价格。本变更为最大兼容性不引入混合计费模式,但必须在 usage log 中完整记录 token 与 image_count,便于后续按数据决定是否新增 `image_plus_token` 计费模式。
### 6. 尺寸档位与参数透传
OpenAI 图片请求的 `size` 参数必须透传给上游;本地只做计费分档,不做 OpenAI 尺寸合法性校验。无论尺寸是否满足官方约束,本地都不能因为未知尺寸或 provider-invalid 尺寸返回 400;如果上游不接受该尺寸,由上游响应错误。
官方 `gpt-image-2` 文档给出的常用尺寸与约束是本地计费分档的依据:
- 常用尺寸:`1024x1024``1536x1024``1024x1536``2048x2048``2048x1152``3840x2160``2160x3840``auto`
- 自定义尺寸:官方支持满足约束的任意 `size`,包括边长、16 像素倍数、长短边比例、总像素范围等约束。
- `2560x1440` 是 2K/QHD 参考边界;超过 `2560x1440` 总像素的输出进入更高档位风险区。
OpenAI 图片尺寸分层必须按以下规则:
```text
empty, auto => 2K
1024x1024 => 1K
1536x1024, 1024x1536 => 2K
1792x1024, 1024x1792 => 2K
2048x2048, 2048x1152, 1152x2048 => 2K
3840x2160, 2160x3840 => 4K
未知且无法解析为正整数 WIDTHxHEIGHT => 2K
未知且 WIDTH * HEIGHT <= 2560*1440 => 2K
未知且 WIDTH * HEIGHT > 2560*1440 => 4K
```
这个规则只决定 `ImageSize` 和扣费档位,不修改请求体,不删除未知参数,不把未知尺寸改写成预设尺寸。
## Risks / Trade-offs
- 历史普通编码分组迁移后仍默认允许生图 → 通过管理员可见开关、上线核对清单和新建分组默认关闭来控制;代码无法可靠判断“普通编码分组”和“图片分组”的业务意图。
- 默认共享现有有效倍率仍保留“图片最终价不直观”的问题 → 这是兼容性选择;需要直观设置图片最终价的分组必须打开 `image_rate_independent`
- 独立图片倍率不会读取用户专属普通倍率 → 这是目标行为;如需要用户级图片独立倍率,应作为后续独立需求实现。
- 通用 Responses 图片工具可能同时输出文本和图片 → 本变更默认仍按图片请求语义计费并完整记录 token;若业务要求文本也收费,应新增独立的混合计费模式,不能混入本次兼容性变更。
- 本地不再拦截未知或 provider-invalid OpenAI 尺寸 → 非法尺寸会消耗一次上游请求失败成本和用户体验往返,但这是为了保证参数透传、兼容官方新增尺寸和第三方兼容提供商;计费只在成功产出最终图片后发生。
- Responses 流式解析需要在客户端断开后继续 drain 上游以完成计费 → 沿用当前流式处理“客户端断开后继续读取上游用于计费”的模式,并只新增轻量 JSON 路径提取。
- 预扣费不在本变更中实现 → 继续使用现有成功后扣费模型,避免失败请求退款、流式中断退款和图片数量未确定时预估错误。
## Migration Plan
1. 新增数据库迁移,添加 `groups.allow_image_generation``groups.image_rate_independent``groups.image_rate_multiplier`
2. 回填现有分组:`openai``gemini``antigravity``allow_image_generation=true``anthropic=false`;所有现有分组 `image_rate_independent=false``image_rate_multiplier=1`
3. 不改写现有 `image_price_1k/2k/4k`,保持默认共享倍率模式下的历史扣费结果。
4. 更新 Ent schema 与生成代码,更新后端 service/handler DTO 和前端类型。
5. 先接入权限判断,确保未开启生图的分组不会到达上游。
6. 再接入图片数量解析和图片计费倍率选择,确保开启生图的分组按图片数量收费。
7. 最后更新前端管理界面、i18n、文档和测试。
8. 回滚时只能通过新迁移回滚字段行为;不能修改已应用迁移文件。
## Open Questions
无。当前方案不依赖未确认的上游新尺寸、新模型或新 endpoint。
@@ -0,0 +1,29 @@
## Why
当前代码把“能否生图”和“如何按图片收费”混在模型、分组倍率、渠道定价与 Responses 工具调用里,导致 OpenAI 普通编码分组在允许 `gpt-5.4` / `gpt-5.5` 时也能通过 `image_generation` tool 产图,并且通用 `/v1/responses` 产图不会稳定写入 `ImageCount`。需要把生图能力、图片倍率模式、图片产出数量归因拆成独立能力,保证普通编码分组可按业务开关生图,开启后既能沿用现有倍率行为,也能按需切换到图片独立倍率。
## What Changes
- 新增分组级生图能力开关,明确控制 `/v1/images/*``gpt-image-*`、显式 `image_generation` tool、Codex 自动注入图片工具等所有生图入口。
- 新增分组级图片倍率模式开关,默认继续共享现有分组有效倍率;打开独立模式后使用图片独立倍率输入框。
- 保留现有 `image_price_1k/2k/4k` 图片价格配置;图片最终扣费由“图片价格 × 当前倍率模式选出的倍率 × 图片数量”决定。
- 统一统计 OpenAI Responses 图片工具产物数量,使 `gpt-5.4` / `gpt-5.5` 通过 `image_generation` tool 产图时进入图片计费,而不是退化成普通 token 计费或无 usage 时不计费。
- 修正专用 Images API 与渠道图片计费场景,按实际图片数量和明确尺寸档位计费,避免固定 `RequestCount=1` 或未知尺寸静默落到 `2K`
- 更新后台分组配置、前端类型、使用说明和测试,覆盖普通编码分组关闭生图、普通编码分组开启生图、独立图片分组承载、生图流式/非流式等场景。
## Capabilities
### New Capabilities
- `image-generation-access-control`: 定义分组级生图能力开关、所有生图意图识别规则、拒绝行为与 Codex 自动注入规则。
- `image-generation-billing-accounting`: 定义图片倍率模式、图片数量归因、尺寸档位、渠道图片价格和用量日志要求。
### Modified Capabilities
- 无。
## Impact
- Backend schema/API: `backend/ent/schema/group.go`、Ent 生成代码、数据库迁移、管理员分组 create/update/list DTO、分组缓存/序列化。
- Backend request gates: `backend/internal/handler/openai_images.go``backend/internal/service/openai_gateway_service.go``backend/internal/service/openai_codex_transform.go`、OpenAI account scheduler 相关模型/图片能力调度入口。
- Backend billing: `backend/internal/service/billing_service.go``backend/internal/service/openai_gateway_service.go``backend/internal/service/gateway_service.go`、usage log 与 account stats 成本计算路径。
- Frontend admin: `frontend/src/types/index.ts``frontend/src/views/admin/GroupsView.vue`、相关 i18n 文案与图片计费展示。
- Tests: OpenAI Images API、OpenAI Responses stream/non-stream/passthrough、分组开关、图片倍率模式、渠道图片计数、尺寸档位与 usage log 断言。
@@ -0,0 +1,118 @@
## ADDED Requirements
### Requirement: Group image generation capability
The system SHALL store a group-level `allow_image_generation` capability flag and SHALL expose it through admin group create, update, list, and detail APIs.
#### Scenario: New group defaults to image generation disabled
- **WHEN** an admin creates a group without providing `allow_image_generation`
- **THEN** the persisted group has `allow_image_generation=false`
#### Scenario: Existing image-capable platform groups are backfilled
- **WHEN** the migration is applied to existing groups
- **THEN** existing `openai`, `gemini`, and `antigravity` groups have `allow_image_generation=true`
- **AND** existing `anthropic` groups have `allow_image_generation=false`
#### Scenario: Admin enables image generation on an ordinary coding group
- **WHEN** an admin updates an `openai` group with `allow_image_generation=true`
- **THEN** the group can use image generation paths subject to the billing requirements
#### Scenario: Admin disables image generation on an ordinary coding group
- **WHEN** an admin updates an `openai` group with `allow_image_generation=false`
- **THEN** the group can still use non-image text model requests
- **AND** image generation intents are denied before upstream dispatch
### Requirement: Image generation intent detection
The system SHALL classify a request as an image generation intent before upstream account scheduling when the endpoint or request body can produce generated images.
#### Scenario: Images endpoint is an image generation intent
- **WHEN** a request targets `/v1/images/generations`, `/v1/images/edits`, `/images/generations`, or `/images/edits`
- **THEN** the request is classified as an image generation intent
#### Scenario: Responses request with image-only model is an image generation intent
- **WHEN** a `/v1/responses` request has a requested model whose normalized name starts with `gpt-image-`
- **THEN** the request is classified as an image generation intent before any model rewrite
#### Scenario: Responses request with image_generation tool is an image generation intent
- **WHEN** a `/v1/responses` request contains any `tools[]` entry with `type == "image_generation"`
- **THEN** the request is classified as an image generation intent
#### Scenario: Responses request with image_generation tool_choice is an image generation intent
- **WHEN** a `/v1/responses` request contains `tool_choice` that explicitly selects `image_generation`
- **THEN** the request is classified as an image generation intent even if `tools[]` is malformed or absent
#### Scenario: Generic tool_choice required is not sufficient by itself
- **WHEN** a `/v1/responses` request contains `tool_choice="required"`
- **AND** the request does not contain an `image_generation` tool
- **THEN** the request is not classified as an image generation intent because of `tool_choice` alone
#### Scenario: Text-only gpt-5.4 request is not an image generation intent
- **WHEN** a `/v1/responses` request uses `model="gpt-5.4"` or `model="gpt-5.5"` without `image_generation` tool and without image `tool_choice`
- **THEN** the request is not classified as an image generation intent
#### Scenario: Intent is checked before and after service-side mutation
- **WHEN** the service mutates a `/v1/responses` request by injecting `image_generation` or rewriting `gpt-image-*` to a Responses text model plus image tool
- **THEN** the final mutated request is checked against the same image generation intent rules before upstream dispatch
### Requirement: Disabled groups reject explicit image generation
The system SHALL reject explicit image generation intents for groups with `allow_image_generation=false` before selecting or calling an upstream account.
#### Scenario: Disabled group rejects Images API
- **WHEN** a group has `allow_image_generation=false`
- **AND** a user calls `/v1/images/generations`
- **THEN** the system returns HTTP 403 with error type `permission_error`
- **AND** no upstream account is selected
- **AND** no usage log is written
#### Scenario: Disabled group rejects Responses image tool
- **WHEN** a group has `allow_image_generation=false`
- **AND** a user calls `/v1/responses` with `tools:[{"type":"image_generation"}]`
- **THEN** the system returns HTTP 403 with error type `permission_error`
- **AND** no upstream account is selected
- **AND** no usage log is written
#### Scenario: Disabled group rejects Responses image-only model rewrite
- **WHEN** a group has `allow_image_generation=false`
- **AND** a user calls `/v1/responses` with `model` starting with `gpt-image-`
- **THEN** the system returns HTTP 403 with error type `permission_error`
- **AND** the request is not rewritten to a text Responses model
#### Scenario: Disabled group permits normal coding request
- **WHEN** a group has `allow_image_generation=false`
- **AND** a user calls `/v1/responses` with `model="gpt-5.4"` and no image generation intent
- **THEN** the request proceeds through the normal text forwarding path
### Requirement: Codex image tool injection respects group capability
The system SHALL only inject the OpenAI Responses `image_generation` tool and bridge instructions for Codex clients when the request group has `allow_image_generation=true`.
#### Scenario: Codex request in enabled group receives image tool
- **WHEN** a Codex CLI `/v1/responses` request belongs to a group with `allow_image_generation=true`
- **AND** the request has no `image_generation` tool
- **THEN** the system injects the existing `image_generation` tool payload
- **AND** the system appends the existing Codex image bridge instructions
#### Scenario: Codex request in disabled group does not receive image tool
- **WHEN** a Codex CLI `/v1/responses` request belongs to a group with `allow_image_generation=false`
- **AND** the request has no explicit image generation intent
- **THEN** the system does not inject `image_generation`
- **AND** the system does not append image bridge instructions
- **AND** the request proceeds as a text request
#### Scenario: Codex explicit image request in disabled group is denied
- **WHEN** a Codex CLI `/v1/responses` request belongs to a group with `allow_image_generation=false`
- **AND** the request explicitly contains `image_generation`
- **THEN** the system returns HTTP 403 with error type `permission_error`
### Requirement: Channel model restrictions remain enforced
The system SHALL keep existing channel model restriction behavior for image and non-image OpenAI requests, including when the advanced OpenAI account scheduler is enabled.
#### Scenario: Advanced scheduler blocks restricted requested model
- **WHEN** a channel has `restrict_models=true`
- **AND** the requested model is not allowed by channel pricing or mapping rules
- **AND** the OpenAI advanced scheduler path is used
- **THEN** the request is rejected before upstream account selection succeeds
#### Scenario: Image generation flag does not bypass channel restrictions
- **WHEN** a group has `allow_image_generation=true`
- **AND** the channel restriction rejects the requested or billing model
- **THEN** the image generation request is rejected
- **AND** no upstream image request is sent
@@ -0,0 +1,225 @@
## ADDED Requirements
### Requirement: Image multiplier mode
The system SHALL calculate image generation cost with group image prices and a selectable image multiplier mode. By default image billing SHALL share the existing effective group multiplier; when `image_rate_independent=true`, image billing SHALL use `image_rate_multiplier`.
#### Scenario: Default image billing shares current effective group multiplier
- **WHEN** a group has `rate_multiplier=0.15`
- **AND** `image_rate_independent=false`
- **AND** `image_price_1k=0.2`
- **AND** a successful image request produces one `1K` image
- **THEN** `actual_cost` is `0.03`
- **AND** the calculation matches current default behavior
#### Scenario: User-specific token multiplier still applies in shared mode
- **WHEN** a user has a user-group token multiplier override of `0.2`
- **AND** the group has `image_rate_independent=false`
- **AND** `image_price_1k=0.5`
- **AND** a successful image request produces one `1K` image
- **THEN** `actual_cost` is `0.1`
- **AND** the applied image multiplier is the same effective multiplier used by token billing
#### Scenario: Independent image multiplier allows direct final price
- **WHEN** a group has `rate_multiplier=0.15`
- **AND** `image_rate_independent=true`
- **AND** `image_rate_multiplier=1`
- **AND** `image_price_1k=0.2`
- **AND** a successful image request produces one `1K` image
- **THEN** `actual_cost` is `0.2`
- **AND** ordinary `rate_multiplier=0.15` is not applied to the image cost
#### Scenario: Independent image multiplier supports image discounts
- **WHEN** a group has `image_rate_independent=true`
- **AND** `image_rate_multiplier=0.5`
- **AND** `image_price_1k=0.2`
- **AND** a successful image request produces two `1K` images
- **THEN** `total_cost` is `0.4`
- **AND** `actual_cost` is `0.2`
#### Scenario: Migration preserves existing image price behavior
- **WHEN** an existing group has `rate_multiplier=0.15` and `image_price_1k=1.3333333333`
- **AND** the migration is applied
- **THEN** the stored `image_price_1k` remains `1.3333333333`
- **AND** the stored `image_rate_independent` is `false`
- **AND** the stored `image_rate_multiplier` is `1`
- **AND** default-mode image billing still produces the historical final price within decimal precision
#### Scenario: Omitted update fields preserve existing multiplier mode
- **WHEN** an admin updates a group without sending `image_rate_independent`
- **AND** without sending `image_rate_multiplier`
- **THEN** the stored image multiplier mode and image multiplier value remain unchanged
#### Scenario: Image multiplier can be zero only by explicit independent mode configuration
- **WHEN** a group has `image_rate_independent=true`
- **AND** `image_rate_multiplier=0`
- **AND** a successful image request produces one image
- **THEN** the image request is free
- **AND** this free-image behavior does not occur unless the group explicitly enables independent image multiplier mode with zero multiplier
### Requirement: Responses image output accounting
The system SHALL count generated image outputs from OpenAI Responses stream, non-stream, and passthrough paths and SHALL return the count in `OpenAIForwardResult.ImageCount`.
#### Scenario: Non-stream Responses image tool output is counted
- **WHEN** a non-stream `/v1/responses` upstream response contains `output[]` item with `type == "image_generation_call"` and non-empty `result`
- **THEN** `OpenAIForwardResult.ImageCount` equals the number of unique final image outputs
- **AND** `OpenAIForwardResult.ImageSize` is the normalized image size tier
#### Scenario: Stream Responses output item is counted
- **WHEN** a stream `/v1/responses` upstream SSE event has `type == "response.output_item.done"`
- **AND** the event item has `type == "image_generation_call"` and non-empty `result`
- **THEN** the streaming result increments the unique final image output count
#### Scenario: Stream Responses completed output is counted
- **WHEN** a stream `/v1/responses` upstream SSE event has `type == "response.completed"`
- **AND** `response.output[]` contains final image generation outputs
- **THEN** the streaming result counts those images without double-counting images already seen in `response.output_item.done`
#### Scenario: Partial image events are not billed as completed images
- **WHEN** a stream response contains `partial_image` events
- **THEN** those partial events do not increment `ImageCount`
- **AND** only final image generation outputs increment `ImageCount`
#### Scenario: gpt-5.4 image tool request is billed as image
- **WHEN** a `/v1/responses` request uses `model="gpt-5.4"` or `model="gpt-5.5"`
- **AND** the request includes an `image_generation` tool
- **AND** the upstream response contains one final image output
- **THEN** the usage log has `image_count=1`
- **AND** the usage log has `billing_mode="image"`
- **AND** image pricing, not token pricing, determines `actual_cost`
#### Scenario: Image output with zero usage is still billed
- **WHEN** an upstream Responses result contains final image output
- **AND** the upstream result has zero or missing token usage
- **THEN** the system writes a usage log
- **AND** the system bills using image pricing
#### Scenario: Responses image request records accompanying token usage
- **WHEN** a `/v1/responses` image tool request returns final images and token usage
- **THEN** the usage log records input tokens, output tokens, image output tokens, and image count
- **AND** the applied billing mode remains `image`
#### Scenario: Responses image request does not introduce hybrid billing by default
- **WHEN** a `/v1/responses` image tool request returns final images and text tokens
- **THEN** the request is billed by image pricing under this change
- **AND** non-image token charges are not added unless a future explicit hybrid billing mode is implemented
### Requirement: OpenAI Images API output accounting
The system SHALL count generated images from dedicated OpenAI Images API stream and non-stream paths and SHALL set `ImageCount` for successful image responses.
#### Scenario: Images non-stream data array is counted
- **WHEN** `/v1/images/generations` returns a non-stream JSON response with top-level `data[]`
- **THEN** `ImageCount` equals the length of `data[]`
#### Scenario: Images stream data array is counted
- **WHEN** `/v1/images/generations` stream response emits SSE data containing top-level `data[]`
- **THEN** `ImageCount` equals the maximum final data array count observed for the request
#### Scenario: Images stream completed event is counted
- **WHEN** `/v1/images/generations` stream response emits `image_generation.completed` with a final image payload
- **THEN** the stream result counts one final image output
#### Scenario: Images stream Responses-form event is counted
- **WHEN** an Images API upstream path emits Responses-form `response.output_item.done` or `response.completed` events with final image outputs
- **THEN** the stream result counts final image outputs using the same de-duplication rules as Responses
### Requirement: Channel image billing uses actual image count
The system SHALL use actual generated image count for channel `billing_mode=image` pricing and SHALL NOT bill multi-image requests as a single request.
#### Scenario: OpenAI channel image billing counts multiple images
- **WHEN** a channel image pricing entry resolves to unit price `0.25`
- **AND** an OpenAI image request produces three images
- **THEN** `total_cost` is `0.75` before the selected image multiplier is applied
- **AND** `RequestCount` passed into unified pricing is `3`
#### Scenario: Gateway channel image billing counts multiple images
- **WHEN** a non-OpenAI gateway image path produces two images
- **AND** channel image pricing resolves for the billing model
- **THEN** `RequestCount` passed into unified pricing is `2`
#### Scenario: Channel image pricing uses shared multiplier by default
- **WHEN** a channel image pricing entry resolves to unit price `0.25`
- **AND** the group has ordinary effective multiplier `0.15`
- **AND** the group has `image_rate_independent=false`
- **AND** the image request produces one image
- **THEN** `actual_cost` is `0.0375`
#### Scenario: Channel image pricing uses independent image multiplier when enabled
- **WHEN** a channel image pricing entry resolves to unit price `0.25`
- **AND** the group has ordinary effective multiplier `0.15`
- **AND** the group has `image_rate_independent=true`
- **AND** the group has `image_rate_multiplier=1`
- **AND** the image request produces one image
- **THEN** `actual_cost` is `0.25`
- **AND** ordinary effective multiplier `0.15` is not applied
#### Scenario: Account stats image pricing receives image count
- **WHEN** account stats pricing uses `billing_mode=image`
- **AND** the request produces multiple images
- **THEN** account stats cost is calculated with the actual image count
### Requirement: Image size tier normalization
The system SHALL normalize OpenAI image sizes to explicit billing tiers for billing only. The system SHALL NOT reject requests locally because of an unknown or provider-invalid `size`; it SHALL forward the original size parameter upstream and let the official upstream API decide whether the request is valid.
#### Scenario: OpenAI 1024 square maps to 1K
- **WHEN** an OpenAI image request specifies `size="1024x1024"`
- **THEN** `ImageSize` is `1K`
#### Scenario: OpenAI landscape and portrait large sizes map to 2K
- **WHEN** an OpenAI image request specifies `1536x1024`, `1024x1536`, `1792x1024`, `1024x1792`, `2048x2048`, `2048x1152`, or `1152x2048`
- **THEN** `ImageSize` is `2K`
#### Scenario: OpenAI gpt-image-2 4K presets map to 4K
- **WHEN** an OpenAI `gpt-image-2` image request specifies `3840x2160` or `2160x3840`
- **THEN** `ImageSize` is `4K`
#### Scenario: OpenAI auto size maps to 2K
- **WHEN** an OpenAI image request omits size or specifies `size="auto"`
- **THEN** `ImageSize` is `2K`
#### Scenario: Custom OpenAI size is forwarded without local validation
- **WHEN** an OpenAI image request specifies a custom explicit `WIDTHxHEIGHT` size
- **THEN** the system forwards the request upstream
- **AND** `ImageSize` is normalized to `2K` or `4K` for billing
#### Scenario: Responses image tool without model uses default image billing model
- **WHEN** a `/v1/responses` request uses an `image_generation` tool without `tool.model`
- **THEN** image size validation and image billing use `gpt-image-2` as the image billing model
#### Scenario: Invalid OpenAI size constraints are delegated upstream
- **WHEN** an OpenAI image request specifies an explicit size that fails OpenAI size constraints
- **THEN** the system forwards the request upstream
- **AND** any invalid-size error comes from the upstream provider response
#### Scenario: Custom OpenAI size tier mapping
- **WHEN** a custom size cannot be parsed as positive `WIDTHxHEIGHT`
- **THEN** `ImageSize` is `2K`
- **WHEN** a custom size parses as positive `WIDTHxHEIGHT`
- **AND** `WIDTH * HEIGHT` is no more than `2560x1440`
- **THEN** `ImageSize` is `2K`
- **WHEN** a custom size parses as positive `WIDTHxHEIGHT`
- **AND** `WIDTH * HEIGHT` exceeds `2560x1440`
- **THEN** `ImageSize` is `4K`
### Requirement: Image usage log semantics
The system SHALL write usage logs for successful image generation with image billing metadata that matches the applied image pricing path.
#### Scenario: Image usage log records image billing mode
- **WHEN** a successful request has `ImageCount > 0`
- **THEN** the usage log has `billing_mode="image"`
- **AND** the usage log records `image_count`
- **AND** the usage log records `image_size` when a normalized size tier is available
#### Scenario: Shared mode image usage log records shared multiplier
- **WHEN** a successful image request is billed with `image_rate_independent=false`
- **AND** the effective ordinary multiplier is `0.15`
- **THEN** `usage_logs.rate_multiplier` is `0.15`
#### Scenario: Independent mode image usage log records image multiplier
- **WHEN** a successful image request is billed with `image_rate_independent=true`
- **AND** `image_rate_multiplier=0.5`
- **THEN** `usage_logs.rate_multiplier` is `0.5`
#### Scenario: Token request usage log is unchanged
- **WHEN** a successful non-image token request is billed
- **THEN** `usage_logs.rate_multiplier` continues to record the ordinary token multiplier
- **AND** `image_count` is `0`
@@ -0,0 +1,72 @@
## 1. Data Model And Migration
- [x] 1.1 Add `allow_image_generation`, `image_rate_independent`, and `image_rate_multiplier` to `backend/ent/schema/group.go`.
- [x] 1.2 Create a new idempotent SQL migration after `133_affiliate_rebate_freeze.sql` for the three group columns.
- [x] 1.3 Backfill existing `openai`, `gemini`, and `antigravity` groups to `allow_image_generation=true` and `anthropic` groups to `false`.
- [x] 1.4 Backfill all existing groups to `image_rate_independent=false` and `image_rate_multiplier=1` without changing existing `image_price_1k/2k/4k`.
- [x] 1.5 Regenerate or update Ent generated group fields, predicates, create/update setters, and query projections.
- [x] 1.6 Add the new fields to backend group domain/service structs, admin create/update inputs, admin responses, and group serialization.
## 2. Admin API And Frontend
- [x] 2.1 Add `allow_image_generation`, `image_rate_independent`, and `image_rate_multiplier` to `CreateGroupRequest` and `UpdateGroupRequest`.
- [x] 2.2 Validate `image_rate_multiplier >= 0` and keep negative image prices using the existing clear-price behavior only for `image_price_*`.
- [x] 2.3 Add the new fields to `frontend/src/types/index.ts` group, create, and update interfaces.
- [x] 2.4 Ensure omitted update fields do not overwrite existing image generation and multiplier mode settings.
- [x] 2.5 Update `frontend/src/views/admin/GroupsView.vue` create/edit forms with a 生图开关, 生图倍率是否独立开关, and conditional image multiplier input.
- [x] 2.6 Add a live final-price preview for `image_price_1k/2k/4k` under shared and independent multiplier modes.
- [x] 2.7 Update group form help text to state that default image billing shares the existing group effective multiplier and independent mode uses the image multiplier input.
- [x] 2.8 Update i18n strings for the new controls and image multiplier mode explanation.
## 3. Image Generation Access Control
- [x] 3.1 Implement a shared helper that detects image generation intent from endpoint, requested model, `tools[]`, and `tool_choice`.
- [x] 3.2 Gate `/v1/images/generations` and `/v1/images/edits` in `backend/internal/handler/openai_images.go` after request parsing and before billing eligibility/account scheduling.
- [x] 3.3 Gate `/v1/responses` explicit `image_generation` tool requests in `backend/internal/service/openai_gateway_service.go` before upstream account scheduling.
- [x] 3.4 Prevent `normalizeOpenAIResponsesImageOnlyModel` from rewriting `gpt-image-*` Responses requests when the group does not allow image generation.
- [x] 3.5 Skip Codex `image_generation` auto-injection and image bridge instructions when the group does not allow image generation.
- [x] 3.6 Re-run image intent detection after service-side request mutation and before upstream dispatch.
- [x] 3.7 Ensure OpenAI advanced scheduler paths apply the same channel `RestrictModels` checks as the load-aware path.
## 4. Responses Image Output Accounting
- [x] 4.1 Add shared parsers for final `image_generation_call.result` outputs in non-stream JSON and SSE payloads.
- [x] 4.2 Extend `openaiStreamingResult` with image count, image size tier, and image billing model fields.
- [x] 4.3 Update `handleStreamingResponse` to count final image outputs while preserving existing stream forwarding and usage parsing.
- [x] 4.4 Update `handleStreamingResponsePassthrough` with the same image output counting.
- [x] 4.5 Update `handleNonStreamingResponse` to count final image outputs from `output[]`.
- [x] 4.6 Update `handleNonStreamingResponsePassthrough` with the same non-stream image output counting.
- [x] 4.7 Populate `OpenAIForwardResult.ImageCount`, `ImageSize`, and image billing model for `gpt-5.4` / `gpt-5.5 + image_generation` requests.
## 5. Images API Accounting And Size Tiers
- [x] 5.1 Extend OpenAI Images API-key stream counting to handle `image_generation.completed`, `response.output_item.done`, and `response.completed`.
- [x] 5.2 Reuse the same final-image de-duplication rules across Images API and Responses API paths.
- [x] 5.3 Keep unknown explicit OpenAI image sizes pass-through and delegate invalid-size errors to upstream.
- [x] 5.4 Map documented OpenAI image sizes to `1K`/`2K`/`4K` billing tiers without rewriting request parameters.
- [x] 5.5 Classify custom OpenAI `WIDTHxHEIGHT` sizes by `2560x1440` total-pixel boundary, falling back to `2K` when unparseable.
## 6. Billing And Usage Logs
- [x] 6.1 Add an image multiplier resolver: shared mode uses the current effective group multiplier, independent mode uses `apiKey.Group.ImageRateMultiplier`.
- [x] 6.2 Update `CalculateImageCost` or its caller contract so image costs use the resolved image multiplier.
- [x] 6.3 Set image usage log `RateMultiplier` to the applied image multiplier; keep token logs unchanged.
- [x] 6.4 Change OpenAI channel image billing `RequestCount` from `1` to `result.ImageCount`.
- [x] 6.5 Change non-OpenAI gateway channel image billing `RequestCount` from `1` to `result.ImageCount`.
- [x] 6.6 Pass actual image count into account stats pricing for `billing_mode=image`.
- [x] 6.7 Ensure `ImageCount > 0` writes a usage log and bills even when upstream token usage is zero.
- [x] 6.8 Record accompanying token usage for Responses image tool requests while keeping default billing mode as `image`.
## 7. Tests And Documentation
- [x] 7.1 Add backend tests for disabled group rejecting `/v1/images/*`, `gpt-image-*` Responses, explicit `image_generation`, and image `tool_choice`.
- [x] 7.2 Add backend tests proving disabled Codex groups do not receive injected image tools while enabled Codex groups still do.
- [x] 7.3 Add backend tests proving omitted group update fields preserve existing image generation and multiplier mode settings.
- [x] 7.4 Add Responses stream and non-stream tests for `gpt-5.4` / `gpt-5.5 + image_generation` image counting and image billing.
- [x] 7.5 Add Images API stream tests for `image_generation.completed`, `response.output_item.done`, and `response.completed` counting.
- [x] 7.6 Add billing tests for shared mode `rate_multiplier=0.15`, `image_price_1k=0.2`, final `actual_cost=0.03`.
- [x] 7.7 Add billing tests for independent mode `rate_multiplier=0.15`, `image_rate_multiplier=1`, `image_price_1k=0.2`, final `actual_cost=0.2`.
- [x] 7.8 Add channel image billing tests proving multi-image requests use `RequestCount=ImageCount` in both shared and independent multiplier modes.
- [x] 7.9 Add size-tier tests for known OpenAI sizes and unknown explicit size pass-through.
- [x] 7.10 Add Responses image tool tests proving token usage is recorded but default billing remains image-mode only.
- [x] 7.11 Update `2ue/image-billing-risk-analysis.md` or add a linked follow-up note that points to this OpenSpec change as the normalized solution.