From 6a3f8b10058ce18b6f1811489f53b0706f9cc613 Mon Sep 17 00:00:00 2001
From: creamlike1024
Date: Thu, 25 Dec 2025 13:34:26 +0800
Subject: [PATCH 1/2] feat: add image completion ratio
---
controller/option.go | 9 ++
dto/gemini.go | 13 ++-
dto/openai_response.go | 1 +
model/option.go | 3 +
relay/channel/gemini/relay-gemini.go | 86 ++++++++------
relay/compatible_handler.go | 19 ++-
relay/helper/price.go | 3 +
setting/ratio_setting/exposed_cache.go | 12 +-
setting/ratio_setting/model_ratio.go | 66 ++++++++++-
types/price_data.go | 3 +-
web/src/components/settings/RatioSetting.jsx | 1 +
web/src/helpers/render.jsx | 109 +++++++++++++-----
web/src/hooks/usage-logs/useUsageLogsData.jsx | 4 +-
.../Setting/Ratio/ModelRatioSettings.jsx | 27 +++++
14 files changed, 279 insertions(+), 77 deletions(-)
diff --git a/controller/option.go b/controller/option.go
index 89b2fc4d52a4..a49a0426eeec 100644
--- a/controller/option.go
+++ b/controller/option.go
@@ -146,6 +146,15 @@ func UpdateOption(c *gin.Context) {
})
return
}
+ case "ImageOutputRatio":
+ err = ratio_setting.UpdateImageOutputRatioByJSONString(option.Value.(string))
+ if err != nil {
+ c.JSON(http.StatusOK, gin.H{
+ "success": false,
+ "message": "图片输出倍率设置失败: " + err.Error(),
+ })
+ return
+ }
case "AudioRatio":
err = ratio_setting.UpdateAudioRatioByJSONString(option.Value.(string))
if err != nil {
diff --git a/dto/gemini.go b/dto/gemini.go
index 1ee71a719cc8..fd126dba1ce2 100644
--- a/dto/gemini.go
+++ b/dto/gemini.go
@@ -346,14 +346,15 @@ type GeminiChatResponse struct {
}
type GeminiUsageMetadata struct {
- PromptTokenCount int `json:"promptTokenCount"`
- CandidatesTokenCount int `json:"candidatesTokenCount"`
- TotalTokenCount int `json:"totalTokenCount"`
- ThoughtsTokenCount int `json:"thoughtsTokenCount"`
- PromptTokensDetails []GeminiPromptTokensDetails `json:"promptTokensDetails"`
+ PromptTokenCount int `json:"promptTokenCount"`
+ CandidatesTokenCount int `json:"candidatesTokenCount"`
+ TotalTokenCount int `json:"totalTokenCount"`
+ ThoughtsTokenCount int `json:"thoughtsTokenCount"`
+ PromptTokensDetails []GeminiTokensDetails `json:"promptTokensDetails"`
+ CandidatesTokensDetails []GeminiTokensDetails `json:"candidatesTokensDetails"`
}
-type GeminiPromptTokensDetails struct {
+type GeminiTokensDetails struct {
Modality string `json:"modality"`
TokenCount int `json:"tokenCount"`
}
diff --git a/dto/openai_response.go b/dto/openai_response.go
index 6baee78ccdba..3bf560518f3b 100644
--- a/dto/openai_response.go
+++ b/dto/openai_response.go
@@ -259,6 +259,7 @@ type InputTokenDetails struct {
type OutputTokenDetails struct {
TextTokens int `json:"text_tokens"`
+ ImageTokens int `json:"image_tokens"`
AudioTokens int `json:"audio_tokens"`
ReasoningTokens int `json:"reasoning_tokens"`
}
diff --git a/model/option.go b/model/option.go
index e9fd50d7f357..2f74230bafe7 100644
--- a/model/option.go
+++ b/model/option.go
@@ -119,6 +119,7 @@ func InitOptionMap() {
common.OptionMap["UserUsableGroups"] = setting.UserUsableGroups2JSONString()
common.OptionMap["CompletionRatio"] = ratio_setting.CompletionRatio2JSONString()
common.OptionMap["ImageRatio"] = ratio_setting.ImageRatio2JSONString()
+ common.OptionMap["ImageOutputRatio"] = ratio_setting.ImageOutputRatio2JSONString()
common.OptionMap["AudioRatio"] = ratio_setting.AudioRatio2JSONString()
common.OptionMap["AudioCompletionRatio"] = ratio_setting.AudioCompletionRatio2JSONString()
common.OptionMap["TopUpLink"] = common.TopUpLink
@@ -426,6 +427,8 @@ func updateOptionMap(key string, value string) (err error) {
err = ratio_setting.UpdateCacheRatioByJSONString(value)
case "ImageRatio":
err = ratio_setting.UpdateImageRatioByJSONString(value)
+ case "ImageOutputRatio":
+ err = ratio_setting.UpdateImageOutputRatioByJSONString(value)
case "AudioRatio":
err = ratio_setting.UpdateAudioRatioByJSONString(value)
case "AudioCompletionRatio":
diff --git a/relay/channel/gemini/relay-gemini.go b/relay/channel/gemini/relay-gemini.go
index f75a921404ae..498382936058 100644
--- a/relay/channel/gemini/relay-gemini.go
+++ b/relay/channel/gemini/relay-gemini.go
@@ -1072,9 +1072,34 @@ func handleFinalStream(c *gin.Context, info *relaycommon.RelayInfo, resp *dto.Ch
return nil
}
+func applyGeminiTokensDetailsToPromptUsage(usage *dto.Usage, details []dto.GeminiTokensDetails) {
+ for _, detail := range details {
+ switch detail.Modality {
+ case "AUDIO":
+ usage.PromptTokensDetails.AudioTokens = detail.TokenCount
+ case "TEXT":
+ usage.PromptTokensDetails.TextTokens = detail.TokenCount
+ case "IMAGE":
+ usage.PromptTokensDetails.ImageTokens = detail.TokenCount
+ }
+ }
+}
+
+func applyGeminiTokensDetailsToCompletionUsage(usage *dto.Usage, details []dto.GeminiTokensDetails) {
+ for _, detail := range details {
+ switch detail.Modality {
+ case "AUDIO":
+ usage.CompletionTokenDetails.AudioTokens = detail.TokenCount
+ case "TEXT":
+ usage.CompletionTokenDetails.TextTokens = detail.TokenCount
+ case "IMAGE":
+ usage.CompletionTokenDetails.ImageTokens = detail.TokenCount
+ }
+ }
+}
+
func geminiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response, callback func(data string, geminiResponse *dto.GeminiChatResponse) bool) (*dto.Usage, *types.NewAPIError) {
var usage = &dto.Usage{}
- var imageCount int
responseText := strings.Builder{}
helper.StreamScannerHandler(c, resp, info, func(data string) bool {
@@ -1085,12 +1110,8 @@ func geminiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
return false
}
- // 统计图片数量
for _, candidate := range geminiResponse.Candidates {
for _, part := range candidate.Content.Parts {
- if part.InlineData != nil && part.InlineData.MimeType != "" {
- imageCount++
- }
if part.Text != "" {
responseText.WriteString(part.Text)
}
@@ -1098,31 +1119,30 @@ func geminiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
}
// 更新使用量统计
- if geminiResponse.UsageMetadata.TotalTokenCount != 0 {
+ if geminiResponse.UsageMetadata.TotalTokenCount != 0 ||
+ geminiResponse.UsageMetadata.PromptTokenCount != 0 ||
+ geminiResponse.UsageMetadata.CandidatesTokenCount != 0 ||
+ geminiResponse.UsageMetadata.ThoughtsTokenCount != 0 ||
+ len(geminiResponse.UsageMetadata.PromptTokensDetails) > 0 ||
+ len(geminiResponse.UsageMetadata.CandidatesTokensDetails) > 0 {
usage.PromptTokens = geminiResponse.UsageMetadata.PromptTokenCount
- usage.CompletionTokens = geminiResponse.UsageMetadata.CandidatesTokenCount + geminiResponse.UsageMetadata.ThoughtsTokenCount
- usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount
- usage.TotalTokens = geminiResponse.UsageMetadata.TotalTokenCount
- for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails {
- if detail.Modality == "AUDIO" {
- usage.PromptTokensDetails.AudioTokens = detail.TokenCount
- } else if detail.Modality == "TEXT" {
- usage.PromptTokensDetails.TextTokens = detail.TokenCount
- }
+ if geminiResponse.UsageMetadata.TotalTokenCount != 0 {
+ usage.TotalTokens = geminiResponse.UsageMetadata.TotalTokenCount
+ usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens
+ } else {
+ usage.CompletionTokens = geminiResponse.UsageMetadata.CandidatesTokenCount + geminiResponse.UsageMetadata.ThoughtsTokenCount
+ usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
}
+ usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount
+ applyGeminiTokensDetailsToPromptUsage(usage, geminiResponse.UsageMetadata.PromptTokensDetails)
+ applyGeminiTokensDetailsToCompletionUsage(usage, geminiResponse.UsageMetadata.CandidatesTokensDetails)
}
return callback(data, &geminiResponse)
})
- if imageCount != 0 {
- if usage.CompletionTokens == 0 {
- usage.CompletionTokens = imageCount * 1400
- }
- }
-
usage.PromptTokensDetails.TextTokens = usage.PromptTokens
- if usage.TotalTokens > 0 {
+ if usage.TotalTokens > 0 && usage.PromptTokens > 0 {
usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens
}
@@ -1228,23 +1248,23 @@ func GeminiChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
}
fullTextResponse := responseGeminiChat2OpenAI(c, &geminiResponse)
fullTextResponse.Model = info.UpstreamModelName
+
usage := dto.Usage{
- PromptTokens: geminiResponse.UsageMetadata.PromptTokenCount,
- CompletionTokens: geminiResponse.UsageMetadata.CandidatesTokenCount,
- TotalTokens: geminiResponse.UsageMetadata.TotalTokenCount,
+ PromptTokens: geminiResponse.UsageMetadata.PromptTokenCount,
+ TotalTokens: geminiResponse.UsageMetadata.TotalTokenCount,
}
usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount
- usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens
-
- for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails {
- if detail.Modality == "AUDIO" {
- usage.PromptTokensDetails.AudioTokens = detail.TokenCount
- } else if detail.Modality == "TEXT" {
- usage.PromptTokensDetails.TextTokens = detail.TokenCount
- }
+ if usage.TotalTokens > 0 {
+ usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens
+ } else {
+ usage.CompletionTokens = geminiResponse.UsageMetadata.CandidatesTokenCount + geminiResponse.UsageMetadata.ThoughtsTokenCount
+ usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
}
+ applyGeminiTokensDetailsToPromptUsage(&usage, geminiResponse.UsageMetadata.PromptTokensDetails)
+ applyGeminiTokensDetailsToCompletionUsage(&usage, geminiResponse.UsageMetadata.CandidatesTokensDetails)
+
fullTextResponse.Usage = usage
switch info.RelayFormat {
diff --git a/relay/compatible_handler.go b/relay/compatible_handler.go
index d92c990a7815..4d2545ca024c 100644
--- a/relay/compatible_handler.go
+++ b/relay/compatible_handler.go
@@ -203,6 +203,7 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
cacheTokens := usage.PromptTokensDetails.CachedTokens
imageTokens := usage.PromptTokensDetails.ImageTokens
audioTokens := usage.PromptTokensDetails.AudioTokens
+ completionImageTokens := usage.CompletionTokenDetails.ImageTokens
completionTokens := usage.CompletionTokens
cachedCreationTokens := usage.PromptTokensDetails.CachedCreationTokens
@@ -212,6 +213,7 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
completionRatio := relayInfo.PriceData.CompletionRatio
cacheRatio := relayInfo.PriceData.CacheRatio
imageRatio := relayInfo.PriceData.ImageRatio
+ imageOutputRatio := relayInfo.PriceData.ImageOutputRatio
modelRatio := relayInfo.PriceData.ModelRatio
groupRatio := relayInfo.PriceData.GroupRatioInfo.GroupRatio
modelPrice := relayInfo.PriceData.ModelPrice
@@ -223,10 +225,12 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
dImageTokens := decimal.NewFromInt(int64(imageTokens))
dAudioTokens := decimal.NewFromInt(int64(audioTokens))
dCompletionTokens := decimal.NewFromInt(int64(completionTokens))
+ dCompletionImageTokens := decimal.NewFromInt(int64(completionImageTokens))
dCachedCreationTokens := decimal.NewFromInt(int64(cachedCreationTokens))
dCompletionRatio := decimal.NewFromFloat(completionRatio)
dCacheRatio := decimal.NewFromFloat(cacheRatio)
dImageRatio := decimal.NewFromFloat(imageRatio)
+ dImageOutputRatio := decimal.NewFromFloat(imageOutputRatio)
dModelRatio := decimal.NewFromFloat(modelRatio)
dGroupRatio := decimal.NewFromFloat(groupRatio)
dModelPrice := decimal.NewFromFloat(modelPrice)
@@ -338,7 +342,14 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
Add(imageTokensWithRatio).
Add(dCachedCreationTokensWithRatio)
- completionQuota := dCompletionTokens.Mul(dCompletionRatio)
+ baseCompletionTokens := dCompletionTokens
+ var completionImageTokensWithRatio decimal.Decimal
+ if !dCompletionImageTokens.IsZero() {
+ baseCompletionTokens = baseCompletionTokens.Sub(dCompletionImageTokens)
+ completionImageTokensWithRatio = dCompletionImageTokens.Mul(dImageOutputRatio)
+ }
+
+ completionQuota := baseCompletionTokens.Mul(dCompletionRatio).Add(completionImageTokensWithRatio)
quotaCalculateDecimal = promptQuota.Add(completionQuota).Mul(ratio)
@@ -418,7 +429,11 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
if imageTokens != 0 {
other["image"] = true
other["image_ratio"] = imageRatio
- other["image_output"] = imageTokens
+ other["image_input_tokens"] = imageTokens
+ }
+ if completionImageTokens != 0 {
+ other["completion_image_tokens"] = completionImageTokens
+ other["image_output_ratio"] = imageOutputRatio
}
if cachedCreationTokens != 0 {
other["cache_creation_tokens"] = cachedCreationTokens
diff --git a/relay/helper/price.go b/relay/helper/price.go
index c310220fe543..0e10d8600412 100644
--- a/relay/helper/price.go
+++ b/relay/helper/price.go
@@ -60,6 +60,7 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
var cacheCreationRatio1h float64
var audioRatio float64
var audioCompletionRatio float64
+ var imageOutputRatio float64
var freeModel bool
if !usePrice {
preConsumedTokens := common.Max(promptTokens, common.PreConsumedQuota)
@@ -85,6 +86,7 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
// 固定1h和5min缓存写入价格的比例
cacheCreationRatio1h = cacheCreationRatio * claudeCacheCreation1hMultiplier
imageRatio, _ = ratio_setting.GetImageRatio(info.OriginModelName)
+ imageOutputRatio, _ = ratio_setting.GetImageOutputRatio(info.OriginModelName)
audioRatio = ratio_setting.GetAudioRatio(info.OriginModelName)
audioCompletionRatio = ratio_setting.GetAudioCompletionRatio(info.OriginModelName)
ratio := modelRatio * groupRatioInfo.GroupRatio
@@ -124,6 +126,7 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
UsePrice: usePrice,
CacheRatio: cacheRatio,
ImageRatio: imageRatio,
+ ImageOutputRatio: imageOutputRatio,
AudioRatio: audioRatio,
AudioCompletionRatio: audioCompletionRatio,
CacheCreationRatio: cacheCreationRatio,
diff --git a/setting/ratio_setting/exposed_cache.go b/setting/ratio_setting/exposed_cache.go
index 2fe2cd09b49f..0ff03ca1fa7c 100644
--- a/setting/ratio_setting/exposed_cache.go
+++ b/setting/ratio_setting/exposed_cache.go
@@ -42,10 +42,14 @@ func GetExposedData() gin.H {
return cloneGinH(c.data)
}
newData := gin.H{
- "model_ratio": GetModelRatioCopy(),
- "completion_ratio": GetCompletionRatioCopy(),
- "cache_ratio": GetCacheRatioCopy(),
- "model_price": GetModelPriceCopy(),
+ "model_ratio": GetModelRatioCopy(),
+ "completion_ratio": GetCompletionRatioCopy(),
+ "cache_ratio": GetCacheRatioCopy(),
+ "model_price": GetModelPriceCopy(),
+ "image_ratio": GetImageRatioCopy(),
+ "image_output_ratio": GetImageOutputRatioCopy(),
+ "audio_ratio": GetAudioRatioCopy(),
+ "audio_completion_ratio": GetAudioCompletionRatioCopy(),
}
exposedData.Store(&exposedCache{
data: newData,
diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go
index 00e8ccffab55..f21893f14bde 100644
--- a/setting/ratio_setting/model_ratio.go
+++ b/setting/ratio_setting/model_ratio.go
@@ -362,6 +362,11 @@ func InitRatioSettings() {
imageRatioMap = defaultImageRatio
imageRatioMapMutex.Unlock()
+ // initialize imageOutputRatioMap
+ imageOutputRatioMapMutex.Lock()
+ imageOutputRatioMap = defaultImageOutputRatio
+ imageOutputRatioMapMutex.Unlock()
+
// initialize audioRatioMap
audioRatioMapMutex.Lock()
audioRatioMap = defaultAudioRatio
@@ -687,6 +692,10 @@ var defaultImageRatio = map[string]float64{
}
var imageRatioMap map[string]float64
var imageRatioMapMutex sync.RWMutex
+
+var defaultImageOutputRatio = map[string]float64{}
+var imageOutputRatioMap map[string]float64
+var imageOutputRatioMapMutex sync.RWMutex
var (
audioRatioMap map[string]float64 = nil
audioRatioMapMutex = sync.RWMutex{}
@@ -710,7 +719,11 @@ func UpdateImageRatioByJSONString(jsonStr string) error {
imageRatioMapMutex.Lock()
defer imageRatioMapMutex.Unlock()
imageRatioMap = make(map[string]float64)
- return common.Unmarshal([]byte(jsonStr), &imageRatioMap)
+ err := common.Unmarshal([]byte(jsonStr), &imageRatioMap)
+ if err == nil {
+ InvalidateExposedDataCache()
+ }
+ return err
}
func GetImageRatio(name string) (float64, bool) {
@@ -723,6 +736,37 @@ func GetImageRatio(name string) (float64, bool) {
return ratio, true
}
+func ImageOutputRatio2JSONString() string {
+ imageOutputRatioMapMutex.RLock()
+ defer imageOutputRatioMapMutex.RUnlock()
+ jsonBytes, err := common.Marshal(imageOutputRatioMap)
+ if err != nil {
+ common.SysError("error marshalling image output ratio: " + err.Error())
+ }
+ return string(jsonBytes)
+}
+
+func UpdateImageOutputRatioByJSONString(jsonStr string) error {
+ imageOutputRatioMapMutex.Lock()
+ defer imageOutputRatioMapMutex.Unlock()
+ imageOutputRatioMap = make(map[string]float64)
+ err := common.Unmarshal([]byte(jsonStr), &imageOutputRatioMap)
+ if err == nil {
+ InvalidateExposedDataCache()
+ }
+ return err
+}
+
+func GetImageOutputRatio(name string) (float64, bool) {
+ imageOutputRatioMapMutex.RLock()
+ defer imageOutputRatioMapMutex.RUnlock()
+ ratio, ok := imageOutputRatioMap[name]
+ if !ok {
+ return 1, false
+ }
+ return ratio, true
+}
+
func AudioRatio2JSONString() string {
audioRatioMapMutex.RLock()
defer audioRatioMapMutex.RUnlock()
@@ -788,6 +832,26 @@ func GetAudioCompletionRatioCopy() map[string]float64 {
return copyMap
}
+func GetImageRatioCopy() map[string]float64 {
+ imageRatioMapMutex.RLock()
+ defer imageRatioMapMutex.RUnlock()
+ copyMap := make(map[string]float64, len(imageRatioMap))
+ for k, v := range imageRatioMap {
+ copyMap[k] = v
+ }
+ return copyMap
+}
+
+func GetImageOutputRatioCopy() map[string]float64 {
+ imageOutputRatioMapMutex.RLock()
+ defer imageOutputRatioMapMutex.RUnlock()
+ copyMap := make(map[string]float64, len(imageOutputRatioMap))
+ for k, v := range imageOutputRatioMap {
+ copyMap[k] = v
+ }
+ return copyMap
+}
+
func GetModelRatioCopy() map[string]float64 {
modelRatioMapMutex.RLock()
defer modelRatioMapMutex.RUnlock()
diff --git a/types/price_data.go b/types/price_data.go
index 93044f865253..9f942d3eca42 100644
--- a/types/price_data.go
+++ b/types/price_data.go
@@ -18,6 +18,7 @@ type PriceData struct {
CacheCreation5mRatio float64
CacheCreation1hRatio float64
ImageRatio float64
+ ImageOutputRatio float64
AudioRatio float64
AudioCompletionRatio float64
OtherRatios map[string]float64
@@ -33,5 +34,5 @@ type PerCallPriceData struct {
}
func (p PriceData) ToSetting() string {
- return fmt.Sprintf("ModelPrice: %f, ModelRatio: %f, CompletionRatio: %f, CacheRatio: %f, GroupRatio: %f, UsePrice: %t, CacheCreationRatio: %f, CacheCreation5mRatio: %f, CacheCreation1hRatio: %f, QuotaToPreConsume: %d, ImageRatio: %f, AudioRatio: %f, AudioCompletionRatio: %f", p.ModelPrice, p.ModelRatio, p.CompletionRatio, p.CacheRatio, p.GroupRatioInfo.GroupRatio, p.UsePrice, p.CacheCreationRatio, p.CacheCreation5mRatio, p.CacheCreation1hRatio, p.QuotaToPreConsume, p.ImageRatio, p.AudioRatio, p.AudioCompletionRatio)
+ return fmt.Sprintf("ModelPrice: %f, ModelRatio: %f, CompletionRatio: %f, CacheRatio: %f, GroupRatio: %f, UsePrice: %t, CacheCreationRatio: %f, CacheCreation5mRatio: %f, CacheCreation1hRatio: %f, QuotaToPreConsume: %d, ImageRatio: %f, ImageOutputRatio: %f, AudioRatio: %f, AudioCompletionRatio: %f", p.ModelPrice, p.ModelRatio, p.CompletionRatio, p.CacheRatio, p.GroupRatioInfo.GroupRatio, p.UsePrice, p.CacheCreationRatio, p.CacheCreation5mRatio, p.CacheCreation1hRatio, p.QuotaToPreConsume, p.ImageRatio, p.ImageOutputRatio, p.AudioRatio, p.AudioCompletionRatio)
}
diff --git a/web/src/components/settings/RatioSetting.jsx b/web/src/components/settings/RatioSetting.jsx
index 059b0f5f984c..dbe6944868da 100644
--- a/web/src/components/settings/RatioSetting.jsx
+++ b/web/src/components/settings/RatioSetting.jsx
@@ -40,6 +40,7 @@ const RatioSetting = () => {
GroupRatio: '',
GroupGroupRatio: '',
ImageRatio: '',
+ ImageOutputRatio: '',
AudioRatio: '',
AudioCompletionRatio: '',
AutoGroups: '',
diff --git a/web/src/helpers/render.jsx b/web/src/helpers/render.jsx
index 450c5799b426..55b6a78f1d7f 100644
--- a/web/src/helpers/render.jsx
+++ b/web/src/helpers/render.jsx
@@ -1171,7 +1171,9 @@ export function renderModelPrice(
cacheRatio = 1.0,
image = false,
imageRatio = 1.0,
- imageOutputTokens = 0,
+ imageInputTokens = 0,
+ completionImageTokens = 0,
+ imageOutputRatio = 1.0,
webSearch = false,
webSearchCallCount = 0,
webSearchPrice = 0,
@@ -1214,22 +1216,31 @@ export function renderModelPrice(
let completionRatioPrice = modelRatio * 2.0 * completionRatio;
let cacheRatioPrice = modelRatio * 2.0 * cacheRatio;
let imageRatioPrice = modelRatio * 2.0 * imageRatio;
+ let imageOutputRatioPrice = modelRatio * 2.0 * imageOutputRatio;
// Calculate effective input tokens (non-cached + cached with ratio applied)
let effectiveInputTokens =
inputTokens - cacheTokens + cacheTokens * cacheRatio;
// Handle image tokens if present
- if (image && imageOutputTokens > 0) {
+ if (image && imageInputTokens > 0) {
effectiveInputTokens =
- inputTokens - imageOutputTokens + imageOutputTokens * imageRatio;
+ inputTokens - imageInputTokens + imageInputTokens * imageRatio;
}
if (audioInputTokens > 0) {
effectiveInputTokens -= audioInputTokens;
}
+
+ const baseCompletionTokens = Math.max(
+ completionTokens - completionImageTokens,
+ 0,
+ );
+ const shouldSplitOutput =
+ completionImageTokens > 0 && imageOutputRatio !== completionRatio;
let price =
(effectiveInputTokens / 1000000) * inputRatioPrice * groupRatio +
(audioInputTokens / 1000000) * audioInputPrice * groupRatio +
- (completionTokens / 1000000) * completionRatioPrice * groupRatio +
+ (baseCompletionTokens / 1000000) * completionRatioPrice * groupRatio +
+ (completionImageTokens / 1000000) * imageOutputRatioPrice * groupRatio +
(webSearchCallCount / 1000) * webSearchPrice * groupRatio +
(fileSearchCallCount / 1000) * fileSearchPrice * groupRatio +
imageGenerationCallPrice * groupRatio;
@@ -1249,17 +1260,44 @@ export function renderModelPrice(
},
)}
-
- {i18next.t(
- '输出价格:{{symbol}}{{price}} * {{completionRatio}} = {{symbol}}{{total}} / 1M tokens (补全倍率: {{completionRatio}})',
- {
- symbol: symbol,
- price: (inputRatioPrice * rate).toFixed(6),
- total: (completionRatioPrice * rate).toFixed(6),
- completionRatio: completionRatio,
- },
- )}
-
+ {shouldSplitOutput ? (
+ <>
+
+ {i18next.t(
+ '文字输出价格:{{symbol}}{{price}} * {{completionRatio}} = {{symbol}}{{total}} / 1M tokens (补全倍率: {{completionRatio}})',
+ {
+ symbol: symbol,
+ price: (inputRatioPrice * rate).toFixed(6),
+ total: (completionRatioPrice * rate).toFixed(6),
+ completionRatio: completionRatio,
+ },
+ )}
+
+
+ {i18next.t(
+ '图片输出价格:{{symbol}}{{price}} * {{imageOutputRatio}} = {{symbol}}{{total}} / 1M tokens (图片输出倍率: {{imageOutputRatio}})',
+ {
+ symbol: symbol,
+ price: (inputRatioPrice * rate).toFixed(6),
+ total: (imageOutputRatioPrice * rate).toFixed(6),
+ imageOutputRatio: imageOutputRatio,
+ },
+ )}
+
+ >
+ ) : (
+
+ {i18next.t(
+ '输出价格:{{symbol}}{{price}} * {{completionRatio}} = {{symbol}}{{total}} / 1M tokens (补全倍率: {{completionRatio}})',
+ {
+ symbol: symbol,
+ price: (inputRatioPrice * rate).toFixed(6),
+ total: (completionRatioPrice * rate).toFixed(6),
+ completionRatio: completionRatio,
+ },
+ )}
+
+ )}
{cacheTokens > 0 && (
{i18next.t(
@@ -1273,7 +1311,7 @@ export function renderModelPrice(
)}
)}
- {image && imageOutputTokens > 0 && (
+ {image && imageInputTokens > 0 && (
{i18next.t(
'图片输入价格:{{symbol}}{{price}} * {{ratio}} = {{symbol}}{{total}} / 1M tokens (图片倍率: {{imageRatio}})',
@@ -1315,12 +1353,12 @@ export function renderModelPrice(
{(() => {
// 构建输入部分描述
let inputDesc = '';
- if (image && imageOutputTokens > 0) {
+ if (image && imageInputTokens > 0) {
inputDesc = i18next.t(
'(输入 {{nonImageInput}} tokens + 图片输入 {{imageInput}} tokens * {{imageRatio}} / 1M tokens * {{symbol}}{{price}}',
{
- nonImageInput: inputTokens - imageOutputTokens,
- imageInput: imageOutputTokens,
+ nonImageInput: inputTokens - imageInputTokens,
+ imageInput: imageInputTokens,
imageRatio: imageRatio,
symbol: symbol,
price: (inputRatioPrice * rate).toFixed(6),
@@ -1360,16 +1398,29 @@ export function renderModelPrice(
}
// 构建输出部分描述
- const outputDesc = i18next.t(
- '输出 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}}) * {{ratioType}} {{ratio}}',
- {
- completion: completionTokens,
- symbol: symbol,
- compPrice: (completionRatioPrice * rate).toFixed(6),
- ratio: groupRatio,
- ratioType: ratioLabel,
- },
- );
+ const outputDesc = shouldSplitOutput
+ ? i18next.t(
+ '输出 文字输出 {{textCompletion}} tokens / 1M tokens * {{symbol}}{{textCompPrice}} + 图片输出 {{imageCompletion}} tokens / 1M tokens * {{symbol}}{{imageCompPrice}}) * {{ratioType}} {{ratio}}',
+ {
+ textCompletion: baseCompletionTokens,
+ imageCompletion: completionImageTokens,
+ symbol: symbol,
+ textCompPrice: (completionRatioPrice * rate).toFixed(6),
+ imageCompPrice: (imageOutputRatioPrice * rate).toFixed(6),
+ ratio: groupRatio,
+ ratioType: ratioLabel,
+ },
+ )
+ : i18next.t(
+ '输出 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}}) * {{ratioType}} {{ratio}}',
+ {
+ completion: completionTokens,
+ symbol: symbol,
+ compPrice: (completionRatioPrice * rate).toFixed(6),
+ ratio: groupRatio,
+ ratioType: ratioLabel,
+ },
+ );
// 构建额外服务描述
const extraServices = [
diff --git a/web/src/hooks/usage-logs/useUsageLogsData.jsx b/web/src/hooks/usage-logs/useUsageLogsData.jsx
index 2d0ed3249516..645c565b0a59 100644
--- a/web/src/hooks/usage-logs/useUsageLogsData.jsx
+++ b/web/src/hooks/usage-logs/useUsageLogsData.jsx
@@ -451,7 +451,9 @@ export const useLogsData = () => {
other?.cache_ratio || 1.0,
other?.image || false,
other?.image_ratio || 0,
- other?.image_output || 0,
+ other?.image_input_tokens || 0,
+ other?.completion_image_tokens || 0,
+ other?.image_output_ratio || 1.0,
other?.web_search || false,
other?.web_search_call_count || 0,
other?.web_search_price || 0,
diff --git a/web/src/pages/Setting/Ratio/ModelRatioSettings.jsx b/web/src/pages/Setting/Ratio/ModelRatioSettings.jsx
index b298cc787a15..f00879ed5411 100644
--- a/web/src/pages/Setting/Ratio/ModelRatioSettings.jsx
+++ b/web/src/pages/Setting/Ratio/ModelRatioSettings.jsx
@@ -45,6 +45,7 @@ export default function ModelRatioSettings(props) {
CacheRatio: '',
CompletionRatio: '',
ImageRatio: '',
+ ImageOutputRatio: '',
AudioRatio: '',
AudioCompletionRatio: '',
ExposeRatioEnabled: false,
@@ -246,6 +247,32 @@ export default function ModelRatioSettings(props) {
/>
+
+
+ verifyJSON(value),
+ message: '不是合法的 JSON 字符串',
+ },
+ ]}
+ onChange={(value) =>
+ setInputs({ ...inputs, ImageOutputRatio: value })
+ }
+ />
+
+
Date: Mon, 29 Dec 2025 20:24:52 +0800
Subject: [PATCH 2/2] feat: add openai /v1/images image output token billing
---
relay/channel/openai/relay-openai.go | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/relay/channel/openai/relay-openai.go b/relay/channel/openai/relay-openai.go
index ac44312eb0d4..ceb9f99b7a9c 100644
--- a/relay/channel/openai/relay-openai.go
+++ b/relay/channel/openai/relay-openai.go
@@ -12,8 +12,10 @@ import (
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/relay/channel/openrouter"
relaycommon "github.com/QuantumNous/new-api/relay/common"
+ relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/QuantumNous/new-api/relay/helper"
"github.com/QuantumNous/new-api/service"
+ "github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/QuantumNous/new-api/types"
@@ -582,6 +584,11 @@ func OpenaiHandlerWithUsage(c *gin.Context, info *relaycommon.RelayInfo, resp *h
usageResp.PromptTokensDetails.ImageTokens += usageResp.InputTokensDetails.ImageTokens
usageResp.PromptTokensDetails.TextTokens += usageResp.InputTokensDetails.TextTokens
}
+ if (info.RelayMode == relayconstant.RelayModeImagesGenerations || info.RelayMode == relayconstant.RelayModeImagesEdits) && usageResp.OutputTokens > 0 {
+ if _, ok := ratio_setting.GetImageOutputRatio(info.OriginModelName); ok {
+ usageResp.CompletionTokenDetails.ImageTokens += usageResp.OutputTokens
+ }
+ }
applyUsagePostProcessing(info, &usageResp.Usage, responseBody)
return &usageResp.Usage, nil
}