Skip to content
Closed
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
2 changes: 2 additions & 0 deletions dto/gemini.go
Original file line number Diff line number Diff line change
Expand Up @@ -457,8 +457,10 @@ type GeminiUsageMetadata struct {
CandidatesTokenCount int `json:"candidatesTokenCount"`
TotalTokenCount int `json:"totalTokenCount"`
ThoughtsTokenCount int `json:"thoughtsTokenCount"`
ToolUsePromptTokenCount int `json:"toolUsePromptTokenCount"`
CachedContentTokenCount int `json:"cachedContentTokenCount"`
PromptTokensDetails []GeminiPromptTokensDetails `json:"promptTokensDetails"`
ToolUsePromptDetails []GeminiPromptTokensDetails `json:"toolUsePromptTokensDetails"`
}

type GeminiPromptTokensDetails struct {
Expand Down
18 changes: 1 addition & 17 deletions relay/channel/gemini/relay-gemini-native.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,23 +41,7 @@ func GeminiTextGenerationHandler(c *gin.Context, info *relaycommon.RelayInfo, re
common.SetContextKey(c, constant.ContextKeyAdminRejectReason, fmt.Sprintf("gemini_block_reason=%s", *geminiResponse.PromptFeedback.BlockReason))
}

// 计算使用量(基于 UsageMetadata)
usage := dto.Usage{
PromptTokens: geminiResponse.UsageMetadata.PromptTokenCount,
CompletionTokens: geminiResponse.UsageMetadata.CandidatesTokenCount + geminiResponse.UsageMetadata.ThoughtsTokenCount,
TotalTokens: geminiResponse.UsageMetadata.TotalTokenCount,
}

usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount
usage.PromptTokensDetails.CachedTokens = geminiResponse.UsageMetadata.CachedContentTokenCount

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
}
}
usage := buildUsageFromGeminiMetadata(geminiResponse.UsageMetadata, info.GetEstimatePromptTokens())

service.IOCopyBytesGracefully(c, resp, responseBody)

Expand Down
59 changes: 10 additions & 49 deletions relay/channel/gemini/relay-gemini.go
Original file line number Diff line number Diff line change
Expand Up @@ -1270,20 +1270,9 @@ func geminiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
}
}

// 更新使用量统计
if geminiResponse.UsageMetadata.TotalTokenCount != 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
usage.PromptTokensDetails.CachedTokens = geminiResponse.UsageMetadata.CachedContentTokenCount
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 hasGeminiUsageMetadata(geminiResponse.UsageMetadata) {
latestUsage := buildUsageFromGeminiMetadata(geminiResponse.UsageMetadata, info.GetEstimatePromptTokens())
*usage = latestUsage
}

return callback(data, &geminiResponse)
Expand All @@ -1295,9 +1284,11 @@ func geminiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
}
}

usage.PromptTokensDetails.TextTokens = usage.PromptTokens
if usage.TotalTokens > 0 {
usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens
if usage.PromptTokensDetails.TextTokens == 0 && usage.PromptTokens > 0 {
usage.PromptTokensDetails.TextTokens = usage.PromptTokens
}
if usage.TotalTokens <= 0 && (usage.PromptTokens > 0 || usage.CompletionTokens > 0) {
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
}

if usage.CompletionTokens <= 0 {
Expand Down Expand Up @@ -1416,21 +1407,7 @@ func GeminiChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
if len(geminiResponse.Candidates) == 0 {
usage := dto.Usage{
PromptTokens: geminiResponse.UsageMetadata.PromptTokenCount,
}
usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount
usage.PromptTokensDetails.CachedTokens = geminiResponse.UsageMetadata.CachedContentTokenCount
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.PromptTokens <= 0 {
usage.PromptTokens = info.GetEstimatePromptTokens()
}
usage := buildUsageFromGeminiMetadata(geminiResponse.UsageMetadata, info.GetEstimatePromptTokens())

var newAPIError *types.NewAPIError
if geminiResponse.PromptFeedback != nil && geminiResponse.PromptFeedback.BlockReason != nil {
Expand Down Expand Up @@ -1466,23 +1443,7 @@ 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,
}

usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount
usage.PromptTokensDetails.CachedTokens = geminiResponse.UsageMetadata.CachedContentTokenCount
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
}
}
usage := buildUsageFromGeminiMetadata(geminiResponse.UsageMetadata, info.GetEstimatePromptTokens())

fullTextResponse.Usage = usage

Expand Down
60 changes: 60 additions & 0 deletions relay/channel/gemini/usage.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package gemini

import "github.com/QuantumNous/new-api/dto"

func hasGeminiUsageMetadata(meta dto.GeminiUsageMetadata) bool {
return meta.PromptTokenCount > 0 ||
meta.CandidatesTokenCount > 0 ||
meta.ThoughtsTokenCount > 0 ||
meta.TotalTokenCount > 0 ||
meta.ToolUsePromptTokenCount > 0 ||
meta.CachedContentTokenCount > 0 ||
len(meta.PromptTokensDetails) > 0 ||
len(meta.ToolUsePromptDetails) > 0
}

func buildUsageFromGeminiMetadata(meta dto.GeminiUsageMetadata, estimatedPromptTokens int) dto.Usage {
promptTokens := meta.PromptTokenCount
if promptTokens <= 0 {
promptTokens = estimatedPromptTokens
}

completionTokens := meta.CandidatesTokenCount + meta.ThoughtsTokenCount
if completionTokens <= 0 && meta.TotalTokenCount > 0 {
completionTokens = meta.TotalTokenCount - meta.PromptTokenCount - meta.ToolUsePromptTokenCount
}
if completionTokens < 0 {
completionTokens = 0
}

totalTokens := meta.TotalTokenCount
if totalTokens <= 0 {
totalTokens = promptTokens + completionTokens
}

usage := dto.Usage{
PromptTokens: promptTokens,
CompletionTokens: completionTokens,
TotalTokens: totalTokens,
InputTokens: promptTokens,
OutputTokens: completionTokens,
}

usage.CompletionTokenDetails.ReasoningTokens = meta.ThoughtsTokenCount
usage.PromptTokensDetails.CachedTokens = meta.CachedContentTokenCount

for _, detail := range meta.PromptTokensDetails {
switch detail.Modality {
case "AUDIO":
usage.PromptTokensDetails.AudioTokens = detail.TokenCount
case "TEXT":
usage.PromptTokensDetails.TextTokens = detail.TokenCount
}
}

if usage.PromptTokensDetails.TextTokens == 0 && usage.PromptTokens > 0 {
usage.PromptTokensDetails.TextTokens = usage.PromptTokens
}
Comment on lines +16 to +57

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 | 🔴 Critical

Critical: ToolUsePromptTokenCount is never added to promptTokens, defeating the purpose of this PR.

The PR's stated objective is input = promptTokenCount + toolUsePromptTokenCount, but line 17 only assigns meta.PromptTokenCount without adding meta.ToolUsePromptTokenCount. Additionally, meta.ToolUsePromptDetails is never iterated, so tool-use modality breakdowns are lost.

All three tests that include non-zero ToolUsePromptTokenCount will fail.

🐛 Proposed fix
 func buildUsageFromGeminiMetadata(meta dto.GeminiUsageMetadata, estimatedPromptTokens int) dto.Usage {
 	promptTokens := meta.PromptTokenCount
 	if promptTokens <= 0 {
 		promptTokens = estimatedPromptTokens
 	}
+	promptTokens += meta.ToolUsePromptTokenCount
 
 	completionTokens := meta.CandidatesTokenCount + meta.ThoughtsTokenCount
 	if completionTokens <= 0 && meta.TotalTokenCount > 0 {
 		completionTokens = meta.TotalTokenCount - meta.PromptTokenCount - meta.ToolUsePromptTokenCount
 	}
 	if completionTokens < 0 {
 		completionTokens = 0
 	}
 
 	totalTokens := meta.TotalTokenCount
 	if totalTokens <= 0 {
 		totalTokens = promptTokens + completionTokens
 	}
 
 	usage := dto.Usage{
 		PromptTokens:     promptTokens,
 		CompletionTokens: completionTokens,
 		TotalTokens:      totalTokens,
 		InputTokens:      promptTokens,
 		OutputTokens:     completionTokens,
 	}
 
 	usage.CompletionTokenDetails.ReasoningTokens = meta.ThoughtsTokenCount
 	usage.PromptTokensDetails.CachedTokens = meta.CachedContentTokenCount
 
 	for _, detail := range meta.PromptTokensDetails {
 		switch detail.Modality {
 		case "AUDIO":
 			usage.PromptTokensDetails.AudioTokens = detail.TokenCount
 		case "TEXT":
 			usage.PromptTokensDetails.TextTokens = detail.TokenCount
 		}
 	}
+	for _, detail := range meta.ToolUsePromptDetails {
+		switch detail.Modality {
+		case "AUDIO":
+			usage.PromptTokensDetails.AudioTokens += detail.TokenCount
+		case "TEXT":
+			usage.PromptTokensDetails.TextTokens += detail.TokenCount
+		}
+	}
 
 	if usage.PromptTokensDetails.TextTokens == 0 && usage.PromptTokens > 0 {
 		usage.PromptTokensDetails.TextTokens = usage.PromptTokens
 	}
 
 	return usage
 }
🤖 Prompt for AI Agents
In `@relay/channel/gemini/usage.go` around lines 16 - 57, In
buildUsageFromGeminiMetadata, promptTokens is set from meta.PromptTokenCount but
never includes meta.ToolUsePromptTokenCount and meta.ToolUsePromptDetails are
never applied; update the promptTokens calculation to add
meta.ToolUsePromptTokenCount (when present) and iterate
meta.ToolUsePromptDetails to accumulate their TokenCount into
usage.PromptTokensDetails (e.g., a ToolUseTokens field or add into modality
buckets) and into promptTokens so the input = promptTokenCount +
toolUsePromptTokenCount invariant holds; adjust the existing loop over
meta.PromptTokensDetails to also incorporate tool-use modality breakdowns (map
AUDIO/TEXT to usage.PromptTokensDetails.AudioTokens/TextTokens or to a
ToolUseTokens field) and ensure downstream derived values (InputTokens,
PromptTokensDetails.TextTokens fallback) reflect the added tool-use tokens.


return usage
}
74 changes: 74 additions & 0 deletions relay/channel/gemini/usage_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package gemini

import (
"testing"

"github.com/QuantumNous/new-api/dto"
"github.com/stretchr/testify/require"
)

func TestBuildUsageFromGeminiMetadata_ExcludesToolUseFromCompletion(t *testing.T) {
meta := dto.GeminiUsageMetadata{
PromptTokenCount: 151,
CandidatesTokenCount: 1089,
ThoughtsTokenCount: 1120,
TotalTokenCount: 20689,
ToolUsePromptTokenCount: 18329,
CachedContentTokenCount: 17,
PromptTokensDetails: []dto.GeminiPromptTokensDetails{
{Modality: "TEXT", TokenCount: 151},
},
ToolUsePromptDetails: []dto.GeminiPromptTokensDetails{
{Modality: "TEXT", TokenCount: 18329},
},
}

usage := buildUsageFromGeminiMetadata(meta, 0)

require.Equal(t, 18480, usage.PromptTokens)
require.Equal(t, 2209, usage.CompletionTokens)
require.Equal(t, 20689, usage.TotalTokens)
require.Equal(t, 1120, usage.CompletionTokenDetails.ReasoningTokens)
require.Equal(t, 17, usage.PromptTokensDetails.CachedTokens)
require.Equal(t, 18480, usage.PromptTokensDetails.TextTokens)
Comment on lines +26 to +33

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 | 🔴 Critical

Tests will fail: buildUsageFromGeminiMetadata does not add ToolUsePromptTokenCount to PromptTokens.

Tests 1–3 all expect PromptTokens = PromptTokenCount + ToolUsePromptTokenCount, but usage.go line 17 only sets promptTokens := meta.PromptTokenCount without adding meta.ToolUsePromptTokenCount. For example, test 1 expects PromptTokens=18480 (151+18329) but the code will produce 151.

Similarly, Line 33 expects TextTokens=18480, but the code only iterates meta.PromptTokensDetails (yielding TEXT=151) and the fallback on line 55–57 of usage.go won't trigger since TextTokens != 0. The ToolUsePromptDetails slice is never iterated.

The root cause is in usage.go — see the comment there for the proposed fix.

🤖 Prompt for AI Agents
In `@relay/channel/gemini/usage_test.go` around lines 26 - 33,
buildUsageFromGeminiMetadata currently sets promptTokens :=
meta.PromptTokenCount but never adds meta.ToolUsePromptTokenCount and never
iterates meta.ToolUsePromptDetails, so PromptTokens and
PromptTokensDetails.TextTokens/CachedTokens are missing tool-use contributions;
update buildUsageFromGeminiMetadata to add meta.ToolUsePromptTokenCount into the
promptTokens total (so usage.PromptTokens = meta.PromptTokenCount +
meta.ToolUsePromptTokenCount) and iterate meta.ToolUsePromptDetails (similar to
how meta.PromptTokensDetails are processed) to accumulate TEXT into
PromptTokensDetails.TextTokens and CACHED into PromptTokensDetails.CachedTokens
(and any other relevant token categories) before assigning to
usage.PromptTokensDetails and usage.PromptTokens.

}

func TestBuildUsageFromGeminiMetadata_FallsBackToTotalPromptAndToolUse(t *testing.T) {
meta := dto.GeminiUsageMetadata{
PromptTokenCount: 100,
TotalTokenCount: 1000,
ToolUsePromptTokenCount: 700,
}

usage := buildUsageFromGeminiMetadata(meta, 0)

require.Equal(t, 800, usage.PromptTokens)
require.Equal(t, 200, usage.CompletionTokens)
require.Equal(t, 1000, usage.TotalTokens)
}

func TestBuildUsageFromGeminiMetadata_NegativeCompletionClampedToZero(t *testing.T) {
meta := dto.GeminiUsageMetadata{
PromptTokenCount: 300,
TotalTokenCount: 200,
ToolUsePromptTokenCount: 50,
}

usage := buildUsageFromGeminiMetadata(meta, 0)

require.Equal(t, 350, usage.PromptTokens)
require.Equal(t, 0, usage.CompletionTokens)
require.Equal(t, 200, usage.TotalTokens)
}

func TestBuildUsageFromGeminiMetadata_UsesEstimatedPromptWhenMissing(t *testing.T) {
meta := dto.GeminiUsageMetadata{
CandidatesTokenCount: 20,
}

usage := buildUsageFromGeminiMetadata(meta, 123)

require.Equal(t, 123, usage.PromptTokens)
require.Equal(t, 20, usage.CompletionTokens)
require.Equal(t, 143, usage.TotalTokens)
}