From e732c5842675d2aeeb3faa2af633341fb9d9c1ac Mon Sep 17 00:00:00 2001
From: creamlike1024
Date: Wed, 27 Aug 2025 21:30:52 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20gemini-2.5-flash-image-preview=20?=
=?UTF-8?q?=E6=96=87=E6=9C=AC=E5=92=8C=E5=9B=BE=E7=89=87=E8=BE=93=E5=87=BA?=
=?UTF-8?q?=E8=AE=A1=E8=B4=B9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
dto/gemini.go | 16 ++++----
relay/channel/gemini/relay-gemini-native.go | 36 ++++++++++++++++++
relay/compatible_handler.go | 15 ++++++++
service/token_counter.go | 2 +-
setting/model_setting/gemini.go | 1 +
setting/operation_setting/tools.go | 11 ++++++
setting/ratio_setting/model_ratio.go | 10 +++--
web/src/helpers/render.jsx | 38 +++++++++++++++----
web/src/hooks/usage-logs/useUsageLogsData.jsx | 2 +
9 files changed, 111 insertions(+), 20 deletions(-)
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 bac6c067b..9a929e13e 100644
--- a/service/token_counter.go
+++ b/service/token_counter.go
@@ -304,7 +304,7 @@ func CountRequestToken(c *gin.Context, meta *types.TokenCountMeta, info *relayco
for _, 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 f7d66c798..597576288 100644
--- a/web/src/helpers/render.jsx
+++ b/web/src/helpers/render.jsx
@@ -1080,7 +1080,7 @@ export function renderModelPrice(
cacheRatio = 1.0,
image = false,
imageRatio = 1.0,
- imageOutputTokens = 0,
+ imageInputTokens = 0,
webSearch = false,
webSearchCallCount = 0,
webSearchPrice = 0,
@@ -1090,6 +1090,8 @@ export function renderModelPrice(
audioInputSeperatePrice = false,
audioInputTokens = 0,
audioInputPrice = 0,
+ imageOutputTokens = 0,
+ imageOutputPrice = 0,
) {
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(groupRatio, user_group_ratio);
groupRatio = effectiveGroupRatio;
@@ -1117,9 +1119,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;
@@ -1129,7 +1131,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 (
<>
@@ -1164,7 +1167,7 @@ export function renderModelPrice(
)}
)}
- {image && imageOutputTokens > 0 && (
+ {image && imageInputTokens > 0 && (
{i18next.t(
'图片输入价格:${{price}} * {{ratio}} = ${{total}} / 1M tokens (图片倍率: {{imageRatio}})',
@@ -1191,17 +1194,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,
},
@@ -1271,6 +1283,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 0c6c44529..bd2b1100b 100644
--- a/web/src/hooks/usage-logs/useUsageLogsData.jsx
+++ b/web/src/hooks/usage-logs/useUsageLogsData.jsx
@@ -445,6 +445,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({