Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions dto/openai_response.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import (
"one-api/types"
)

const (
ResponsesOutputTypeImageGenerationCall = "image_generation_call"
)

type SimpleResponse struct {
Usage `json:"usage"`
Error any `json:"error"`
Expand Down Expand Up @@ -273,6 +277,42 @@ func (o *OpenAIResponsesResponse) GetOpenAIError() *types.OpenAIError {
return GetOpenAIError(o.Error)
}

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
}
Comment on lines +280 to +290

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

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.

Suggested change
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.


func (o *OpenAIResponsesResponse) GetQuality() string {
if len(o.Output) == 0 {
return ""
}
for _, output := range o.Output {
if output.Type == ResponsesOutputTypeImageGenerationCall {
return output.Quality
}
}
return ""
}

func (o *OpenAIResponsesResponse) GetSize() string {
if len(o.Output) == 0 {
return ""
}
for _, output := range o.Output {
if output.Type == ResponsesOutputTypeImageGenerationCall {
return output.Size
}
}
return ""
}

type IncompleteDetails struct {
Reasoning string `json:"reasoning"`
}
Expand All @@ -283,6 +323,8 @@ type ResponsesOutput struct {
Status string `json:"status"`
Role string `json:"role"`
Content []ResponsesOutputContent `json:"content"`
Quality string `json:"quality"`
Size string `json:"size"`
}

type ResponsesOutputContent struct {
Expand Down
35 changes: 24 additions & 11 deletions relay/channel/openai/relay_responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ func OaiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
return nil, types.WithOpenAIError(*oaiError, resp.StatusCode)
}

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())
}

// 写入新的 response body
service.IOCopyBytesGracefully(c, resp, responseBody)

Expand Down Expand Up @@ -80,18 +86,25 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
sendResponsesStreamData(c, streamResponse, data)
switch streamResponse.Type {
case "response.completed":
if streamResponse.Response != nil && streamResponse.Response.Usage != nil {
if streamResponse.Response.Usage.InputTokens != 0 {
usage.PromptTokens = streamResponse.Response.Usage.InputTokens
}
if streamResponse.Response.Usage.OutputTokens != 0 {
usage.CompletionTokens = streamResponse.Response.Usage.OutputTokens
}
if streamResponse.Response.Usage.TotalTokens != 0 {
usage.TotalTokens = streamResponse.Response.Usage.TotalTokens
if streamResponse.Response != nil {
if streamResponse.Response.Usage != nil {
if streamResponse.Response.Usage.InputTokens != 0 {
usage.PromptTokens = streamResponse.Response.Usage.InputTokens
}
if streamResponse.Response.Usage.OutputTokens != 0 {
usage.CompletionTokens = streamResponse.Response.Usage.OutputTokens
}
if streamResponse.Response.Usage.TotalTokens != 0 {
usage.TotalTokens = streamResponse.Response.Usage.TotalTokens
}
if streamResponse.Response.Usage.InputTokensDetails != nil {
usage.PromptTokensDetails.CachedTokens = streamResponse.Response.Usage.InputTokensDetails.CachedTokens
}
}
if streamResponse.Response.Usage.InputTokensDetails != nil {
usage.PromptTokensDetails.CachedTokens = streamResponse.Response.Usage.InputTokensDetails.CachedTokens
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())
}
}
case "response.output_text.delta":
Expand Down
13 changes: 13 additions & 0 deletions relay/compatible_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,13 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
fileSearchTool.CallCount, dFileSearchQuota.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"))
dImageGenerationCallQuota = decimal.NewFromFloat(imageGenerationCallPrice).Mul(dGroupRatio).Mul(dQuotaPerUnit)
extraContent += fmt.Sprintf("Image Generation Call 花费 %s", dImageGenerationCallQuota.String())
}

Comment on lines +279 to 286

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

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.

Suggested change
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 花费 ...").

var quotaCalculateDecimal decimal.Decimal

Expand Down Expand Up @@ -331,6 +338,8 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
quotaCalculateDecimal = quotaCalculateDecimal.Add(dFileSearchQuota)
// 添加 audio input 独立计费
quotaCalculateDecimal = quotaCalculateDecimal.Add(audioInputQuota)
// 添加 image generation call 计费
quotaCalculateDecimal = quotaCalculateDecimal.Add(dImageGenerationCallQuota)

quota := int(quotaCalculateDecimal.Round(0).IntPart())
totalTokens := promptTokens + completionTokens
Expand Down Expand Up @@ -429,6 +438,10 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
other["audio_input_token_count"] = audioTokens
other["audio_input_price"] = audioInputPrice
}
if !dImageGenerationCallQuota.IsZero() {
other["image_generation_call"] = true
other["image_generation_call_price"] = imageGenerationCallPrice
}
model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{
ChannelId: relayInfo.ChannelId,
PromptTokens: promptTokens,
Expand Down
40 changes: 40 additions & 0 deletions setting/operation_setting/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,18 @@ const (
FileSearchPrice = 2.5
)

const (
GPTImage1Low1024x1024 = 0.011
GPTImage1Low1024x1536 = 0.016
GPTImage1Low1536x1024 = 0.016
GPTImage1Medium1024x1024 = 0.042
GPTImage1Medium1024x1536 = 0.063
GPTImage1Medium1536x1024 = 0.063
GPTImage1High1024x1024 = 0.167
GPTImage1High1024x1536 = 0.25
GPTImage1High1536x1024 = 0.25
)

const (
// Gemini Audio Input Price
Gemini25FlashPreviewInputAudioPrice = 1.00
Expand Down Expand Up @@ -65,3 +77,31 @@ func GetGeminiInputAudioPricePerMillionTokens(modelName string) float64 {
}
return 0
}

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
}
Comment on lines +81 to +107

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

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.

23 changes: 21 additions & 2 deletions web/src/helpers/render.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -1027,6 +1027,8 @@ export function renderModelPrice(
audioInputSeperatePrice = false,
audioInputTokens = 0,
audioInputPrice = 0,
imageGenerationCall = false,
imageGenerationCallPrice = 0,
) {
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(
groupRatio,
Expand Down Expand Up @@ -1069,7 +1071,8 @@ export function renderModelPrice(
(audioInputTokens / 1000000) * audioInputPrice * groupRatio +
(completionTokens / 1000000) * completionRatioPrice * groupRatio +
(webSearchCallCount / 1000) * webSearchPrice * groupRatio +
(fileSearchCallCount / 1000) * fileSearchPrice * groupRatio;
(fileSearchCallCount / 1000) * fileSearchPrice * groupRatio +
(imageGenerationCallPrice * groupRatio);

return (
<>
Expand Down Expand Up @@ -1131,7 +1134,13 @@ export function renderModelPrice(
})}
</p>
)}
<p></p>
{imageGenerationCall && imageGenerationCallPrice > 0 && (
<p>
{i18next.t('图片生成调用:${{price}} / 1次', {
price: imageGenerationCallPrice,
})}
</p>
)}
<p>
{(() => {
// 构建输入部分描述
Expand Down Expand Up @@ -1211,6 +1220,16 @@ export function renderModelPrice(
},
)
: '',
imageGenerationCall && imageGenerationCallPrice > 0
? i18next.t(
' + 图片生成调用 ${{price}} / 1次 * {{ratioType}} {{ratio}}',
{
price: imageGenerationCallPrice,
ratio: groupRatio,
ratioType: ratioLabel,
},
)
: '',
].join('');

return i18next.t(
Expand Down
2 changes: 2 additions & 0 deletions web/src/hooks/usage-logs/useUsageLogsData.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,8 @@ export const useLogsData = () => {
other?.audio_input_seperate_price || false,
other?.audio_input_token_count || 0,
other?.audio_input_price || 0,
other?.image_generation_call || false,
other?.image_generation_call_price || 0,
);
}
expandDataLocal.push({
Expand Down