mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-19 16:17:56 +00:00
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:
@@ -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", "")
|
||||
|
||||
Reference in New Issue
Block a user