feat: add OpenAI image generation controls
This commit is contained in:
+118
@@ -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
|
||||
+225
@@ -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`
|
||||
Reference in New Issue
Block a user