Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions dto/gemini.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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"`
}
Expand Down
36 changes: 36 additions & 0 deletions relay/channel/gemini/relay-gemini-native.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 响应
Expand Down
15 changes: 15 additions & 0 deletions relay/compatible_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion service/token_counter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions setting/model_setting/gemini.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
11 changes: 11 additions & 0 deletions setting/operation_setting/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ const (
ClaudeWebSearchPrice = 10.00
)

const (
Gemini25FlashImagePreviewImageOutputPrice = 30.00
)

func GetClaudeWebSearchPricePerThousand() float64 {
return ClaudeWebSearchPrice
}
Expand Down Expand Up @@ -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
}
10 changes: 6 additions & 4 deletions setting/ratio_setting/model_ratio.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
38 changes: 30 additions & 8 deletions web/src/helpers/render.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}
Comment on lines +1122 to 1125

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Fix logical error in image input token handling

The condition checks imageInputTokens > 0 but the gating uses image && imageInputTokens > 0. This could lead to incorrect calculations if image is false but imageInputTokens is provided.

-    if (image && imageInputTokens > 0) {
+    if (imageInputTokens > 0) {
       effectiveInputTokens =
         inputTokens - imageInputTokens + imageInputTokens * imageRatio;
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (image && imageInputTokens > 0) {
effectiveInputTokens =
inputTokens - imageOutputTokens + imageOutputTokens * imageRatio;
inputTokens - imageInputTokens + imageInputTokens * imageRatio;
}
if (imageInputTokens > 0) {
effectiveInputTokens =
inputTokens - imageInputTokens + imageInputTokens * imageRatio;
}
🤖 Prompt for AI Agents
In web/src/helpers/render.jsx around lines 1122-1125, the conditional currently
gates the image token adjustment with "image && imageInputTokens > 0" which
skips the calculation when an explicit imageInputTokens value exists but image
is false; change the condition to rely on the presence/value of imageInputTokens
instead (e.g. check that imageInputTokens is not null/undefined and > 0) and
then apply the effectiveInputTokens calculation so the adjustment runs whenever
imageInputTokens is provided and positive.

if (audioInputTokens > 0) {
effectiveInputTokens -= audioInputTokens;
Expand All @@ -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 (
<>
Expand Down Expand Up @@ -1164,7 +1167,7 @@ export function renderModelPrice(
)}
</p>
)}
{image && imageOutputTokens > 0 && (
{image && imageInputTokens > 0 && (
<p>
{i18next.t(
'图片输入价格:${{price}} * {{ratio}} = ${{total}} / 1M tokens (图片倍率: {{imageRatio}})',
Expand All @@ -1191,17 +1194,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,
},
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions web/src/hooks/usage-logs/useUsageLogsData.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down