diff --git a/dto/gemini.go b/dto/gemini.go index 5df67ba0b..cd5d74cdd 100644 --- a/dto/gemini.go +++ b/dto/gemini.go @@ -2,11 +2,12 @@ package dto import ( "encoding/json" - "github.com/gin-gonic/gin" "one-api/common" "one-api/logger" "one-api/types" "strings" + + "github.com/gin-gonic/gin" ) type GeminiChatRequest struct { @@ -268,14 +269,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 []GeminiModalityTokenCount `json:"promptTokensDetails"` + CandidatesTokensDetails []GeminiModalityTokenCount `json:"candidatesTokensDetails"` } -type GeminiPromptTokensDetails struct { +type GeminiModalityTokenCount struct { Modality string `json:"modality"` TokenCount int `json:"tokenCount"` } diff --git a/relay/channel/gemini/relay-gemini-native.go b/relay/channel/gemini/relay-gemini-native.go index 974a22f50..564b86908 100644 --- a/relay/channel/gemini/relay-gemini-native.go +++ b/relay/channel/gemini/relay-gemini-native.go @@ -46,6 +46,32 @@ func GeminiTextGenerationHandler(c *gin.Context, info *relaycommon.RelayInfo, re usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount + if strings.HasPrefix(info.UpstreamModelName, "gemini-2.5-flash-image-preview") { + imageOutputCounts := 0 + for _, candidate := range geminiResponse.Candidates { + for _, part := range candidate.Content.Parts { + if part.InlineData != nil && strings.HasPrefix(part.InlineData.MimeType, "image/") { + imageOutputCounts++ + } + } + } + if imageOutputCounts != 0 { + usage.CompletionTokens = usage.CompletionTokens - imageOutputCounts*1290 + usage.TotalTokens = usage.TotalTokens - imageOutputCounts*1290 + c.Set("gemini_image_tokens", imageOutputCounts*1290) + } + } + + // if strings.HasPrefix(info.UpstreamModelName, "gemini-2.5-flash-image-preview") { + // for _, detail := range geminiResponse.UsageMetadata.CandidatesTokensDetails { + // if detail.Modality == "IMAGE" { + // usage.CompletionTokens = usage.CompletionTokens - detail.TokenCount + // usage.TotalTokens = usage.TotalTokens - detail.TokenCount + // c.Set("gemini_image_tokens", detail.TokenCount) + // } + // } + // } + for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails { if detail.Modality == "AUDIO" { usage.PromptTokensDetails.AudioTokens = detail.TokenCount @@ -136,6 +162,16 @@ func GeminiTextGenerationStreamHandler(c *gin.Context, info *relaycommon.RelayIn usage.PromptTokensDetails.TextTokens = detail.TokenCount } } + + if strings.HasPrefix(info.UpstreamModelName, "gemini-2.5-flash-image-preview") { + for _, detail := range geminiResponse.UsageMetadata.CandidatesTokensDetails { + if detail.Modality == "IMAGE" { + usage.CompletionTokens = usage.CompletionTokens - detail.TokenCount + usage.TotalTokens = usage.TotalTokens - detail.TokenCount + c.Set("gemini_image_tokens", detail.TokenCount) + } + } + } } // 直接发送 GeminiChatResponse 响应 diff --git a/relay/compatible_handler.go b/relay/compatible_handler.go index 1f6c525b5..a3c6ace6e 100644 --- a/relay/compatible_handler.go +++ b/relay/compatible_handler.go @@ -314,11 +314,22 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage } else { quotaCalculateDecimal = dModelPrice.Mul(dQuotaPerUnit).Mul(dGroupRatio) } + var dGeminiImageOutputQuota decimal.Decimal + var imageOutputPrice float64 + if strings.HasPrefix(modelName, "gemini-2.5-flash-image-preview") { + imageOutputPrice = operation_setting.GetGeminiImageOutputPricePerMillionTokens(modelName) + if imageOutputPrice > 0 { + dImageOutputTokens := decimal.NewFromInt(int64(ctx.GetInt("gemini_image_tokens"))) + dGeminiImageOutputQuota = decimal.NewFromFloat(imageOutputPrice).Div(decimal.NewFromInt(1000000)).Mul(dImageOutputTokens).Mul(dGroupRatio).Mul(dQuotaPerUnit) + } + } // 添加 responses tools call 调用的配额 quotaCalculateDecimal = quotaCalculateDecimal.Add(dWebSearchQuota) quotaCalculateDecimal = quotaCalculateDecimal.Add(dFileSearchQuota) // 添加 audio input 独立计费 quotaCalculateDecimal = quotaCalculateDecimal.Add(audioInputQuota) + // 添加 Gemini image output 计费 + quotaCalculateDecimal = quotaCalculateDecimal.Add(dGeminiImageOutputQuota) quota := int(quotaCalculateDecimal.Round(0).IntPart()) totalTokens := promptTokens + completionTokens @@ -413,6 +424,10 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage other["audio_input_token_count"] = audioTokens other["audio_input_price"] = audioInputPrice } + if !dGeminiImageOutputQuota.IsZero() { + other["image_output_token_count"] = ctx.GetInt("gemini_image_tokens") + other["image_output_price"] = imageOutputPrice + } model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{ ChannelId: relayInfo.ChannelId, PromptTokens: promptTokens, diff --git a/service/token_counter.go b/service/token_counter.go index be5c2e80c..da56523fe 100644 --- a/service/token_counter.go +++ b/service/token_counter.go @@ -336,7 +336,7 @@ func CountRequestToken(c *gin.Context, meta *types.TokenCountMeta, info *relayco for i, file := range meta.Files { switch file.FileType { case types.FileTypeImage: - if info.RelayFormat == types.RelayFormatGemini { + if info.RelayFormat == types.RelayFormatGemini && !strings.HasPrefix(model, "gemini-2.5-flash-image-preview") { tkm += 256 } else { token, err := getImageToken(file, model, info.IsStream) diff --git a/setting/model_setting/gemini.go b/setting/model_setting/gemini.go index f132fec88..5412155f1 100644 --- a/setting/model_setting/gemini.go +++ b/setting/model_setting/gemini.go @@ -26,6 +26,7 @@ var defaultGeminiSettings = GeminiSettings{ SupportedImagineModels: []string{ "gemini-2.0-flash-exp-image-generation", "gemini-2.0-flash-exp", + "gemini-2.5-flash-image-preview", }, ThinkingAdapterEnabled: false, ThinkingAdapterBudgetTokensPercentage: 0.6, diff --git a/setting/operation_setting/tools.go b/setting/operation_setting/tools.go index 549a1862e..b87265ee1 100644 --- a/setting/operation_setting/tools.go +++ b/setting/operation_setting/tools.go @@ -24,6 +24,10 @@ const ( ClaudeWebSearchPrice = 10.00 ) +const ( + Gemini25FlashImagePreviewImageOutputPrice = 30.00 +) + func GetClaudeWebSearchPricePerThousand() float64 { return ClaudeWebSearchPrice } @@ -65,3 +69,10 @@ func GetGeminiInputAudioPricePerMillionTokens(modelName string) float64 { } return 0 } + +func GetGeminiImageOutputPricePerMillionTokens(modelName string) float64 { + if strings.HasPrefix(modelName, "gemini-2.5-flash-image-preview") { + return Gemini25FlashImagePreviewImageOutputPrice + } + return 0 +} diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go index f06cd71ef..1a1b0afa8 100644 --- a/setting/ratio_setting/model_ratio.go +++ b/setting/ratio_setting/model_ratio.go @@ -178,6 +178,7 @@ var defaultModelRatio = map[string]float64{ "gemini-2.5-flash-lite-preview-thinking-*": 0.05, "gemini-2.5-flash-lite-preview-06-17": 0.05, "gemini-2.5-flash": 0.15, + "gemini-2.5-flash-image-preview": 0.15, // $0.30(text/image) / 1M tokens "text-embedding-004": 0.001, "chatglm_turbo": 0.3572, // ¥0.005 / 1k tokens "chatglm_pro": 0.7143, // ¥0.01 / 1k tokens @@ -293,10 +294,11 @@ var ( ) var defaultCompletionRatio = map[string]float64{ - "gpt-4-gizmo-*": 2, - "gpt-4o-gizmo-*": 3, - "gpt-4-all": 2, - "gpt-image-1": 8, + "gpt-4-gizmo-*": 2, + "gpt-4o-gizmo-*": 3, + "gpt-4-all": 2, + "gpt-image-1": 8, + "gemini-2.5-flash-image-preview": 8.3333333333, } // InitRatioSettings initializes all model related settings maps diff --git a/web/src/helpers/render.jsx b/web/src/helpers/render.jsx index 65332701b..3d9d8d710 100644 --- a/web/src/helpers/render.jsx +++ b/web/src/helpers/render.jsx @@ -1017,7 +1017,7 @@ export function renderModelPrice( cacheRatio = 1.0, image = false, imageRatio = 1.0, - imageOutputTokens = 0, + imageInputTokens = 0, webSearch = false, webSearchCallCount = 0, webSearchPrice = 0, @@ -1027,6 +1027,8 @@ export function renderModelPrice( audioInputSeperatePrice = false, audioInputTokens = 0, audioInputPrice = 0, + imageOutputTokens = 0, + imageOutputPrice = 0, ) { const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio( groupRatio, @@ -1057,9 +1059,9 @@ export function renderModelPrice( 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; @@ -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 + + (imageOutputTokens / 1000000) * imageOutputPrice * groupRatio; return ( <> @@ -1104,7 +1107,7 @@ export function renderModelPrice( )}
)} - {image && imageOutputTokens > 0 && ( + {image && imageInputTokens > 0 && ({i18next.t( '图片输入价格:${{price}} * {{ratio}} = ${{total}} / 1M tokens (图片倍率: {{imageRatio}})', @@ -1131,17 +1134,26 @@ export function renderModelPrice( })}
)} + {imageOutputPrice > 0 && imageOutputTokens > 0 && ( ++ {i18next.t('图片输出价格:${{price}} * 分组倍率{{ratio}} = ${{total}} / 1M tokens', { + price: imageOutputPrice, + ratio: groupRatio, + total: imageOutputPrice * groupRatio, + })} +
+ )}{(() => { // 构建输入部分描述 let inputDesc = ''; - if (image && imageOutputTokens > 0) { + if (image && imageInputTokens > 0) { inputDesc = i18next.t( '(输入 {{nonImageInput}} tokens + 图片输入 {{imageInput}} tokens * {{imageRatio}} / 1M tokens * ${{price}}', { - nonImageInput: inputTokens - imageOutputTokens, - imageInput: imageOutputTokens, + nonImageInput: inputTokens - imageInputTokens, + imageInput: imageInputTokens, imageRatio: imageRatio, price: inputRatioPrice, }, @@ -1211,6 +1223,16 @@ export function renderModelPrice( }, ) : '', + imageOutputPrice > 0 && imageOutputTokens > 0 + ? i18next.t( + ' + 图片输出 {{tokenCounts}} tokens * ${{price}} / 1M tokens * 分组倍率{{ratio}}', + { + tokenCounts: imageOutputTokens, + price: imageOutputPrice, + ratio: groupRatio, + }, + ) + : '', ].join(''); return i18next.t( diff --git a/web/src/hooks/usage-logs/useUsageLogsData.jsx b/web/src/hooks/usage-logs/useUsageLogsData.jsx index 81f3f539a..3584f1d9b 100644 --- a/web/src/hooks/usage-logs/useUsageLogsData.jsx +++ b/web/src/hooks/usage-logs/useUsageLogsData.jsx @@ -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_output_token_count || 0, + other?.image_output_price || 0, ); } expandDataLocal.push({