Просмотр исходного кода

Merge pull request #1677 from QuantumNous/gemini-2.5-flash-image-preview-billing

feat: gemini-2.5-flash-image-preview 文本和图片输出计费
Seefs 3 месяцев назад
Родитель
Сommit
b7527eb80e

+ 9 - 7
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"`
 }

+ 36 - 0
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 响应

+ 15 - 0
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,

+ 1 - 1
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)

+ 1 - 0
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,

+ 11 - 0
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
+}

+ 6 - 4
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

+ 30 - 8
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(
               )}
             </p>
           )}
-          {image && imageOutputTokens > 0 && (
+          {image && imageInputTokens > 0 && (
             <p>
               {i18next.t(
                 '图片输入价格:${{price}} * {{ratio}} = ${{total}} / 1M tokens (图片倍率: {{imageRatio}})',
@@ -1131,17 +1134,26 @@ export function renderModelPrice(
               })}
             </p>
           )}
+          {imageOutputPrice > 0 && imageOutputTokens > 0 && (
+            <p>
+              {i18next.t('图片输出价格:${{price}} * 分组倍率{{ratio}} = ${{total}} / 1M tokens', {
+                price: imageOutputPrice,
+                ratio: groupRatio,
+                total: imageOutputPrice * groupRatio,
+              })}
+            </p>
+          )}
           <p></p>
           <p>
             {(() => {
               // 构建输入部分描述
               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(

+ 2 - 0
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({