fix(gemini): prompt 被拦截只报缓存命中时 input_tokens 不再为负

- Vertex 拦截 prompt(promptFeedback.blockReason,如 PROHIBITED_CONTENT)时 usageMetadata
  可能只带 cachedContentTokenCount、缺 promptTokenCount,promptTokenCount − cachedContentTokenCount
  得到负的 input_tokens 与负费用
- extractGeminiUsage 与 antigravity 非流式 / 流式(累计用量、message_start)四处换算改为差值下限 0,
  缓存命中仍照上游所报计入 cache_read_input_tokens
- 补充表测试、原生流式拦截用例与 antigravity 两条路径用例
This commit is contained in:
feeeei
2026-10-17 19:09:23 +08:00
parent b0a02a3304
commit 9c41b32695
6 changed files with 69 additions and 7 deletions
@@ -277,11 +277,12 @@ func (p *NonStreamingProcessor) buildResponse(geminiResp *GeminiResponse, respon
}
// 注意:Gemini 的 promptTokenCount 包含 cachedContentTokenCount,
// 但 Claude 的 input_tokens 不包含 cache_read_input_tokens,需要减去
// 但 Claude 的 input_tokens 不包含 cache_read_input_tokens,需要减去。
// prompt 被拦截时上游可能只报 cachedContentTokenCount,差值下限为 0。
usage := ClaudeUsage{}
if geminiResp.UsageMetadata != nil {
cached := geminiResp.UsageMetadata.CachedContentTokenCount
usage.InputTokens = geminiResp.UsageMetadata.PromptTokenCount - cached
usage.InputTokens = max(geminiResp.UsageMetadata.PromptTokenCount-cached, 0)
usage.OutputTokens = geminiResp.UsageMetadata.CandidatesTokenCount + geminiResp.UsageMetadata.ThoughtsTokenCount
usage.CacheReadInputTokens = cached
usage.ImageOutputTokens = geminiResp.UsageMetadata.ImageOutputTokens()
@@ -109,10 +109,11 @@ func (p *StreamingProcessor) ProcessLine(line string) []byte {
// 更新 usage
// 注意:Gemini 的 promptTokenCount 包含 cachedContentTokenCount,
// 但 Claude 的 input_tokens 不包含 cache_read_input_tokens,需要减去
// 但 Claude 的 input_tokens 不包含 cache_read_input_tokens,需要减去。
// prompt 被拦截时上游可能只报 cachedContentTokenCount,差值下限为 0。
if geminiResp.UsageMetadata != nil {
cached := geminiResp.UsageMetadata.CachedContentTokenCount
p.inputTokens = geminiResp.UsageMetadata.PromptTokenCount - cached
p.inputTokens = max(geminiResp.UsageMetadata.PromptTokenCount-cached, 0)
p.outputTokens = geminiResp.UsageMetadata.CandidatesTokenCount + geminiResp.UsageMetadata.ThoughtsTokenCount
p.cacheReadTokens = cached
p.imageOutputTokens = geminiResp.UsageMetadata.ImageOutputTokens()
@@ -190,7 +191,7 @@ func (p *StreamingProcessor) emitMessageStart(v1Resp *V1InternalResponse) []byte
usage := ClaudeUsage{}
if v1Resp.Response.UsageMetadata != nil {
cached := v1Resp.Response.UsageMetadata.CachedContentTokenCount
usage.InputTokens = v1Resp.Response.UsageMetadata.PromptTokenCount - cached
usage.InputTokens = max(v1Resp.Response.UsageMetadata.PromptTokenCount-cached, 0)
usage.OutputTokens = v1Resp.Response.UsageMetadata.CandidatesTokenCount + v1Resp.Response.UsageMetadata.ThoughtsTokenCount
usage.CacheReadInputTokens = cached
usage.ImageOutputTokens = v1Resp.Response.UsageMetadata.ImageOutputTokens()
@@ -38,3 +38,29 @@ func TestGeminiUsageMapping_NoCacheCreationTokens(t *testing.T) {
require.Zero(t, usage.CacheCreationInputTokens)
})
}
// usageMetadata 缺 promptTokenCount、只有 cachedContentTokenCount 时,input_tokens 下限为 0,
// 缓存命中仍计入 cache_read_input_tokens。
func TestGeminiUsageMapping_MissingPromptTokenCountKeepsInputNonNegative(t *testing.T) {
const geminiBody = `{"candidates":[{"content":{"parts":[{"text":"hi"}],"role":"model"},"finishReason":"STOP"}],` +
`"usageMetadata":{"cachedContentTokenCount":30}}`
t.Run("non-stream", func(t *testing.T) {
_, usage, err := TransformGeminiToClaude([]byte(geminiBody), "gemini-3.1-pro-preview")
require.NoError(t, err)
require.NotNil(t, usage)
require.Zero(t, usage.InputTokens)
require.Equal(t, 30, usage.CacheReadInputTokens)
})
t.Run("stream", func(t *testing.T) {
p := NewStreamingProcessor("gemini-3.1-pro-preview")
out := p.ProcessLine(`data: {"response":` + geminiBody + `}`)
require.True(t, strings.Contains(string(out), `"message_start"`))
require.NotContains(t, string(out), `"input_tokens":-`, "message_start 的 input_tokens 不应为负")
_, usage := p.Finish()
require.NotNil(t, usage)
require.Zero(t, usage.InputTokens)
require.Equal(t, 30, usage.CacheReadInputTokens)
})
}
@@ -3036,9 +3036,10 @@ func extractGeminiUsage(data []byte) *ClaudeUsage {
}
// 注意:Gemini 的 promptTokenCount 包含 cachedContentTokenCount,
// 但 Claude 的 input_tokens 不包含 cache_read_input_tokens,需要减去
// 但 Claude 的 input_tokens 不包含 cache_read_input_tokens,需要减去。
// prompt 被拦截时上游可能只报 cachedContentTokenCount,差值下限为 0。
return &ClaudeUsage{
InputTokens: prompt - cached,
InputTokens: max(prompt-cached, 0),
OutputTokens: cand + thoughts,
CacheReadInputTokens: cached,
ImageOutputTokens: imageTokens,
@@ -947,6 +947,16 @@ func TestExtractGeminiUsage(t *testing.T) {
CacheReadInputTokens: 0,
},
},
{
name: "prompt 被拦截只报 cachedContentTokenCount — input 下限为 0",
input: `{"promptFeedback":{"blockReason":"PROHIBITED_CONTENT"},"usageMetadata":{"cachedContentTokenCount":12980}}`,
wantNil: false,
wantUsage: &ClaudeUsage{
InputTokens: 0,
OutputTokens: 0,
CacheReadInputTokens: 12980,
},
},
{
name: "无 usageMetadata",
input: `{"candidates":[]}`,
@@ -180,6 +180,29 @@ func TestGeminiForwardNative_StreamPromptBlockedMarksContentPolicy(t *testing.T)
require.Empty(t, upstreamErrorEventsFromContext(t, c))
}
// Vertex 拦截 prompt 时 usageMetadata 可能只带 cachedContentTokenCount、缺 promptTokenCount:
// input 下限为 0,缓存命中照上游所报计入 cache_read。
func TestGeminiForwardNative_StreamPromptBlockedWithOnlyCachedTokensKeepsInputNonNegative(t *testing.T) {
gin.SetMode(gin.TestMode)
body := `data: {"promptFeedback":{"blockReason":"PROHIBITED_CONTENT"},"usageMetadata":{"cachedContentTokenCount":12980,"totalTokenCount":0,"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.5-flash-lite"}
`
svc := newGeminiSignalService("text/event-stream", body)
c, rec := newGeminiNativeTestContext(t)
result, err := svc.ForwardNative(context.Background(), c, geminiSignalTestAccount(),
"gemini-3.5-flash-lite", "streamGenerateContent", true, geminiSignalTestRequest())
require.NoError(t, err)
require.Equal(t, body, rec.Body.String())
require.Equal(t, 0, result.Usage.InputTokens)
require.Equal(t, 12980, result.Usage.CacheReadInputTokens)
require.Equal(t, 0, result.Usage.OutputTokens)
streamErrs := GetOpsStreamErrors(c)
require.Len(t, streamErrs, 1)
require.Equal(t, "PROHIBITED_CONTENT", streamErrs[0].Code)
}
func TestGeminiForwardNative_EmptyStreamMarksUpstreamFailure(t *testing.T) {
gin.SetMode(gin.TestMode)
svc := newGeminiSignalService("text/event-stream", "")