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 }