fix: openai responses api 未统计图像生成调用计费 - #1813
Conversation
WalkthroughAdds detection and metadata extraction for image_generation_call outputs, propagates quality/size through relay handlers, introduces GPT Image 1 per-call pricing and lookup, integrates image-call billing into postConsumeQuota and logging, surfaces the charge in web UI/usage logs, and refines streaming usage handling to include cached input tokens. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
actor Client
participant Relay as Relay Handler
participant Resp as Responses DTO
participant Ctx as Request Context
participant Billing as postConsumeQuota
participant Pricing as GetGPTImage1PriceOnceCall
participant UI as Web UI / Logs
Client->>Relay: /v1/responses (may stream)
Relay->>Resp: parse outputs
Resp-->>Relay: HasImageGenerationCall? (quality,size)
Relay->>Ctx: set image_generation_call, quality, size
Relay->>Ctx: set usage (incl. input_tokens_details.cached_tokens)
Relay->>Billing: postConsumeQuota invoked
Billing->>Ctx: read image_generation_call, quality, size
Billing->>Pricing: lookup price(quality,size)
Pricing-->>Billing: per-call price
Billing->>Billing: add image-call quota/price to totals
Billing->>UI: log image_generation_call and price
UI-->>Client: render totals (includes image-call line)
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Suggested reviewers
Poem
Pre-merge checks and finishing touches❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing touches
🧪 Generate unit tests
📜 Recent review detailsConfiguration used: CodeRabbit UI Review profile: CHILL Plan: Pro 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
Tip 👮 Agentic pre-merge checks are now available in preview!Pro plan users can now enable pre-merge checks in their settings to enforce checklists before merging PRs.
Please see the documentation for more information. Example: reviews:
pre_merge_checks:
custom_checks:
- name: "Undocumented Breaking Changes"
mode: "warning"
instructions: |
Pass/fail criteria: All breaking changes to public APIs, CLI flags, environment variables, configuration keys, database schemas, or HTTP/GraphQL endpoints must be documented in the "Breaking Change" section of the PR description and in CHANGELOG.md. Exclude purely internal or private changes (e.g., code not exported from package entry points or explicitly marked as internal).Please share your feedback with us on this Discord post. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (8)
dto/openai_response.go (2)
292-315: Name clarity and robustness for quality/size getters.
- Consider more explicit names (GetImageGenerationQuality/GetImageGenerationSize).
- Normalize strings (lowercase/trim) at source to reduce downstream case bugs.
Want me to push a follow-up that adds normalized getters and uses them across the call sites?
320-328: Avoid emitting empty fields when marshalling.Add omitempty to Quality/Size to prevent empty strings being serialized for non-image outputs.
type ResponsesOutput struct { @@ - Quality string `json:"quality"` - Size string `json:"size"` + Quality string `json:"quality,omitempty"` + Size string `json:"size,omitempty"` }relay/compatible_handler.go (1)
441-444: Record more context in logs for observability.Include quality/size/count to aid audits and UI display.
if !dImageGenerationCallQuota.IsZero() { other["image_generation_call"] = true other["image_generation_call_price"] = imageGenerationCallPrice + if v := ctx.GetInt("image_generation_call_count"); v > 0 { other["image_generation_call_count"] = v } + if q := ctx.GetString("image_generation_call_quality"); q != "" { other["image_generation_call_quality"] = q } + if s := ctx.GetString("image_generation_call_size"); s != "" { other["image_generation_call_size"] = s } }relay/channel/openai/relay_responses.go (2)
36-40: Also pass image-generation call count (non-stream).Count calls so billing can multiply by N.
if responsesResponse.HasImageGenerationCall() { c.Set("image_generation_call", true) c.Set("image_generation_call_quality", responsesResponse.GetQuality()) c.Set("image_generation_call_size", responsesResponse.GetSize()) + if cnt := responsesResponse.CountImageGenerationCalls(); cnt > 0 { + c.Set("image_generation_call_count", cnt) + } }
89-108: Streaming: set usage safely (good), but also capture image-call count.Increment count on item-done events to avoid relying solely on the final snapshot.
Apply these diffs:
case "response.completed": if streamResponse.Response != nil { if streamResponse.Response.Usage != nil { @@ } - if streamResponse.Response.HasImageGenerationCall() { + if streamResponse.Response.HasImageGenerationCall() { c.Set("image_generation_call", true) c.Set("image_generation_call_quality", streamResponse.Response.GetQuality()) c.Set("image_generation_call_size", streamResponse.Response.GetSize()) + if cnt := streamResponse.Response.CountImageGenerationCalls(); cnt > 0 { + c.Set("image_generation_call_count", cnt) + } } } @@ case dto.ResponsesOutputTypeItemDone: // 函数调用处理 if streamResponse.Item != nil { switch streamResponse.Item.Type { case dto.BuildInCallWebSearchCall: info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearchPreview].CallCount++ + case dto.ResponsesOutputTypeImageGenerationCall: + // Increment count as items complete; capture last seen quality/size for logging. + current := c.GetInt("image_generation_call_count") + c.Set("image_generation_call_count", current+1) + if streamResponse.Item.Quality != "" { c.Set("image_generation_call_quality", streamResponse.Item.Quality) } + if streamResponse.Item.Size != "" { c.Set("image_generation_call_size", streamResponse.Item.Size) } } }If the stream never reaches “completed”, the per-item path still captures the count.
setting/operation_setting/tools.go (1)
13-23: Add 'last verified' comment and consider externalizing GPT-Image-1 pricesConstants in setting/operation_setting/tools.go (lines 13–23) match OpenAI gpt-image-1 pricing as of September 16, 2025: 1024×1024 — low $0.011 / medium $0.042 / high $0.167; 1024×1536 & 1536×1024 — low $0.016 / medium $0.063 / high $0.25.
- Add an inline comment above these constants: "last verified: 2025-09-16 — OpenAI pricing docs" (include source reference).
- Optional: move these values to config/DB/env to avoid rebuilds when prices change.
web/src/helpers/render.jsx (2)
1137-1143: UI: show call count when >1Currently always renders “/ 1次”. If multiple calls occur, display the count for clarity.
- {imageGenerationCall && imageGenerationCallPrice > 0 && ( + {imageGenerationCall && imageGenerationCallPrice > 0 && ( <p> - {i18next.t('图片生成调用:${{price}} / 1次', { - price: imageGenerationCallPrice, - })} + {i18next.t('图片生成调用:${{price}} / 1次{{times}}', { + price: imageGenerationCallPrice, + times: + (imageGenerationCalls ?? 0) > 1 + ? ` × ${imageGenerationCalls}` + : '', + })} </p> )}Note: uses imageGenerationCalls from the previous comment.
1223-1232: Breakdown string: include image call count for transparencyExtra services summary omits the number of image calls.
- imageGenerationCall && imageGenerationCallPrice > 0 + imageGenerationCall && imageGenerationCallPrice > 0 ? i18next.t( - ' + 图片生成调用 ${{price}} / 1次 * {{ratioType}} {{ratio}}', + ' + 图片生成调用 {{count}}次 / 1次 * ${{price}} * {{ratioType}} {{ratio}}', { - price: imageGenerationCallPrice, + count: imageGenerationCalls ?? (imageGenerationCall ? 1 : 0), + price: imageGenerationCallPrice, ratio: groupRatio, ratioType: ratioLabel, }, ) : '',
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (6)
dto/openai_response.go(3 hunks)relay/channel/openai/relay_responses.go(2 hunks)relay/compatible_handler.go(3 hunks)setting/operation_setting/tools.go(2 hunks)web/src/helpers/render.jsx(4 hunks)web/src/hooks/usage-logs/useUsageLogsData.jsx(1 hunks)
🧰 Additional context used
🧬 Code graph analysis (3)
relay/compatible_handler.go (1)
setting/operation_setting/tools.go (1)
GetGPTImage1PriceOnceCall(81-107)
relay/channel/openai/relay_responses.go (1)
dto/openai_response.go (1)
Usage(221-234)
web/src/helpers/render.jsx (1)
web/src/hooks/model-pricing/useModelPricingData.jsx (1)
groupRatio(50-50)
🔇 Additional comments (4)
relay/compatible_handler.go (1)
342-344: LGTM on quota aggregation hook.Image-generation quota is now included in the final total.
dto/openai_response.go (1)
9-11: No change required — upstreamoutput[].typeis "image_generation_call"
Verified against OpenAI Responses examples and Azure Responses references.web/src/hooks/usage-logs/useUsageLogsData.jsx (1)
450-452: renderModelPrice signature updated; call sites verified.
Definition includes the new params imageGenerationCall and imageGenerationCallPrice (web/src/helpers/render.jsx:1008); the only call site found (web/src/hooks/usage-logs/useUsageLogsData.jsx:428–452) already passes both args — no other call sites located.web/src/helpers/render.jsx (1)
1030-1032: New args added to renderModelPrice — usage-logs call site relies on defaults; confirm forwarding of image flagsSearch found a single invocation at web/src/hooks/usage-logs/useUsageLogsData.jsx:428 calling renderModelPrice(logs[i].prompt_tokens, logs[i].completion_tokens, other?.model_ratio) — it does not pass imageGenerationCall or imageGenerationCallPrice. renderModelPrice is exported at web/src/helpers/render.jsx:1008 and supplies defaults for those params. If usage logs can include image-generation metadata, forward those fields into this call; otherwise relying on the defaults is acceptable.
| func (o *OpenAIResponsesResponse) HasImageGenerationCall() bool { | ||
| if len(o.Output) == 0 { | ||
| return false | ||
| } | ||
| for _, output := range o.Output { | ||
| if output.Type == ResponsesOutputTypeImageGenerationCall { | ||
| return true | ||
| } | ||
| } | ||
| return false | ||
| } |
There was a problem hiding this comment.
Only detects presence; no support for multiple image-generation calls.
If a single Responses object includes multiple image-generation calls, billing will undercount. Provide a count API.
Apply this diff to expose a count helper:
func (o *OpenAIResponsesResponse) HasImageGenerationCall() bool {
@@
}
+// CountImageGenerationCalls returns how many image-generation calls are present.
+func (o *OpenAIResponsesResponse) CountImageGenerationCalls() int {
+ if len(o.Output) == 0 {
+ return 0
+ }
+ count := 0
+ for _, output := range o.Output {
+ if output.Type == ResponsesOutputTypeImageGenerationCall {
+ count++
+ }
+ }
+ return count
+}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| func (o *OpenAIResponsesResponse) HasImageGenerationCall() bool { | |
| if len(o.Output) == 0 { | |
| return false | |
| } | |
| for _, output := range o.Output { | |
| if output.Type == ResponsesOutputTypeImageGenerationCall { | |
| return true | |
| } | |
| } | |
| return false | |
| } | |
| func (o *OpenAIResponsesResponse) HasImageGenerationCall() bool { | |
| if len(o.Output) == 0 { | |
| return false | |
| } | |
| for _, output := range o.Output { | |
| if output.Type == ResponsesOutputTypeImageGenerationCall { | |
| return true | |
| } | |
| } | |
| return false | |
| } | |
| // CountImageGenerationCalls returns how many image-generation calls are present. | |
| func (o *OpenAIResponsesResponse) CountImageGenerationCalls() int { | |
| if len(o.Output) == 0 { | |
| return 0 | |
| } | |
| count := 0 | |
| for _, output := range o.Output { | |
| if output.Type == ResponsesOutputTypeImageGenerationCall { | |
| count++ | |
| } | |
| } | |
| return count | |
| } |
🤖 Prompt for AI Agents
In dto/openai_response.go around lines 280 to 290, the current
HasImageGenerationCall only detects existence of image-generation outputs and
therefore undercounts when multiple image-generation calls exist; add a new
method (e.g., CountImageGenerationCalls) on OpenAIResponsesResponse that
iterates over o.Output, increments a counter for each output with Type ==
ResponsesOutputTypeImageGenerationCall, and returns the total count as an int so
callers can bill correctly for multiple image-generation calls.
| var dImageGenerationCallQuota decimal.Decimal | ||
| var imageGenerationCallPrice float64 | ||
| if ctx.GetBool("image_generation_call") { | ||
| imageGenerationCallPrice = operation_setting.GetGPTImage1PriceOnceCall(ctx.GetString("image_generation_call_quality"), ctx.GetString("image_generation_call_size")) | ||
| dImageGenerationCallQuota = decimal.NewFromFloat(imageGenerationCallPrice).Mul(dGroupRatio).Mul(dQuotaPerUnit) | ||
| extraContent += fmt.Sprintf("Image Generation Call 花费 %s", dImageGenerationCallQuota.String()) | ||
| } | ||
|
|
There was a problem hiding this comment.
Under-billing when multiple image-generation calls occur.
You bill as a single call; if N calls happen, only 1× price is charged.
Apply this minimal diff (count defaulting to 1 for backward compatibility):
var dImageGenerationCallQuota decimal.Decimal
var imageGenerationCallPrice float64
if ctx.GetBool("image_generation_call") {
- imageGenerationCallPrice = operation_setting.GetGPTImage1PriceOnceCall(ctx.GetString("image_generation_call_quality"), ctx.GetString("image_generation_call_size"))
- dImageGenerationCallQuota = decimal.NewFromFloat(imageGenerationCallPrice).Mul(dGroupRatio).Mul(dQuotaPerUnit)
- extraContent += fmt.Sprintf("Image Generation Call 花费 %s", dImageGenerationCallQuota.String())
+ imageGenerationCallPrice = operation_setting.GetGPTImage1PriceOnceCall(
+ ctx.GetString("image_generation_call_quality"),
+ ctx.GetString("image_generation_call_size"),
+ )
+ callCount := ctx.GetInt("image_generation_call_count")
+ if callCount <= 0 {
+ callCount = 1
+ }
+ dImageGenerationCallQuota = decimal.NewFromFloat(imageGenerationCallPrice).
+ Mul(decimal.NewFromInt(int64(callCount))).
+ Mul(dGroupRatio).Mul(dQuotaPerUnit)
+ extraContent += fmt.Sprintf("Image Generation Call %d 次,花费 %s", callCount, dImageGenerationCallQuota.String())
}Note: Best is summing per-call prices when qualities/sizes differ; see suggestions in openai/relay_responses.go.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| var dImageGenerationCallQuota decimal.Decimal | |
| var imageGenerationCallPrice float64 | |
| if ctx.GetBool("image_generation_call") { | |
| imageGenerationCallPrice = operation_setting.GetGPTImage1PriceOnceCall(ctx.GetString("image_generation_call_quality"), ctx.GetString("image_generation_call_size")) | |
| dImageGenerationCallQuota = decimal.NewFromFloat(imageGenerationCallPrice).Mul(dGroupRatio).Mul(dQuotaPerUnit) | |
| extraContent += fmt.Sprintf("Image Generation Call 花费 %s", dImageGenerationCallQuota.String()) | |
| } | |
| var dImageGenerationCallQuota decimal.Decimal | |
| var imageGenerationCallPrice float64 | |
| if ctx.GetBool("image_generation_call") { | |
| imageGenerationCallPrice = operation_setting.GetGPTImage1PriceOnceCall( | |
| ctx.GetString("image_generation_call_quality"), | |
| ctx.GetString("image_generation_call_size"), | |
| ) | |
| callCount := ctx.GetInt("image_generation_call_count") | |
| if callCount <= 0 { | |
| callCount = 1 | |
| } | |
| dImageGenerationCallQuota = decimal.NewFromFloat(imageGenerationCallPrice). | |
| Mul(decimal.NewFromInt(int64(callCount))). | |
| Mul(dGroupRatio).Mul(dQuotaPerUnit) | |
| extraContent += fmt.Sprintf("Image Generation Call %d 次,花费 %s", callCount, dImageGenerationCallQuota.String()) | |
| } |
🤖 Prompt for AI Agents
In relay/compatible_handler.go around lines 279-286, the image-generation cost
is only billed once even if multiple calls occur; read an integer count from the
context (e.g., "image_generation_call_count"), default it to 1 for backward
compatibility if missing or zero, then multiply the per-call price/decimal quota
by that count (use decimal.NewFromInt(int64(count)) when updating
dImageGenerationCallQuota or multiply the float price by float64(count)). Also
update the extraContent string to reflect the count and total cost (e.g., "Image
Generation Call xN 花费 ...").
| func GetGPTImage1PriceOnceCall(quality string, size string) float64 { | ||
| prices := map[string]map[string]float64{ | ||
| "low": { | ||
| "1024x1024": GPTImage1Low1024x1024, | ||
| "1024x1536": GPTImage1Low1024x1536, | ||
| "1536x1024": GPTImage1Low1536x1024, | ||
| }, | ||
| "medium": { | ||
| "1024x1024": GPTImage1Medium1024x1024, | ||
| "1024x1536": GPTImage1Medium1024x1536, | ||
| "1536x1024": GPTImage1Medium1536x1024, | ||
| }, | ||
| "high": { | ||
| "1024x1024": GPTImage1High1024x1024, | ||
| "1024x1536": GPTImage1High1024x1536, | ||
| "1536x1024": GPTImage1High1536x1024, | ||
| }, | ||
| } | ||
|
|
||
| if qualityMap, exists := prices[quality]; exists { | ||
| if price, exists := qualityMap[size]; exists { | ||
| return price | ||
| } | ||
| } | ||
|
|
||
| return GPTImage1High1024x1024 | ||
| } |
There was a problem hiding this comment.
Normalize inputs and avoid per-call map allocation.
- Current lookup is case-sensitive; unknown inputs fall back to the highest price (overbilling). Lowercase/trim inputs.
- Hoist the prices map to a package-level var to avoid allocating it on every call.
Apply these diffs:
+var gptImage1Prices = map[string]map[string]float64{
+ "low": {
+ "1024x1024": GPTImage1Low1024x1024,
+ "1024x1536": GPTImage1Low1024x1536,
+ "1536x1024": GPTImage1Low1536x1024,
+ },
+ "medium": {
+ "1024x1024": GPTImage1Medium1024x1024,
+ "1024x1536": GPTImage1Medium1024x1536,
+ "1536x1024": GPTImage1Medium1536x1024,
+ },
+ "high": {
+ "1024x1024": GPTImage1High1024x1024,
+ "1024x1536": GPTImage1High1024x1536,
+ "1536x1024": GPTImage1High1536x1024,
+ },
+}
+
func GetGPTImage1PriceOnceCall(quality string, size string) float64 {
- prices := map[string]map[string]float64{
- "low": { ... },
- "medium": { ... },
- "high": { ... },
- }
-
- if qualityMap, exists := prices[quality]; exists {
- if price, exists := qualityMap[size]; exists {
+ q := strings.ToLower(strings.TrimSpace(quality))
+ s := strings.ToLower(strings.TrimSpace(size))
+ if qualityMap, exists := gptImage1Prices[q]; exists {
+ if price, exists := qualityMap[s]; exists {
return price
}
}
return GPTImage1High1024x1024
}Optionally, support common synonyms ("standard"→"medium", "hd"→"high"). I can add a small alias map if desired.
🤖 Prompt for AI Agents
In setting/operation_setting/tools.go around lines 81 to 107, the function
allocates the prices map on every call and performs a case-sensitive lookup that
can overbill on unknown inputs; move the nested prices map to a package-level
variable (and an alias map for synonyms like "standard"→"medium", "hd"→"high"),
then in GetGPTImage1PriceOnceCall normalize inputs with strings.TrimSpace and
strings.ToLower, map any synonym to its canonical key, perform the lookup
against the package-level map, and if not found return a safe default (e.g., the
"low" 1024x1024 price) instead of the highest price to avoid overbilling.
fix: openai responses api 未统计图像生成调用计费
…ai-image-handling fix: openai image request handling
Close #1797

Summary by CodeRabbit
New Features
Bug Fixes