mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-12 15:21:09 +00:00
fix(relay): follow-up billing integrity and conversion completions (#7170)
Deferred follow-ups from the relaykit-tools review cycle, verified by live end-to-end billing tests: - billing: normalize Gemini modality keys consistently between stream merge and settlement (case/whitespace variants no longer drop independent audio/image pricing) and sum duplicate modality entries on both paths - billing: sync legacy flat Claude cache-creation fields from the CacheCreation sub-object (including zeroing) and fall back to flat fields only when the snapshot never carried a sub-object, closing a stale 1h-cache overcharge path in cascaded deployments - relay: move Chat-to-Claude and Chat-to-Gemini stream conversion state from gin.Context onto RelayInfo and reset it with SendResponseCount in InitChannelMeta, so channel retries start clean while per-request state (stream error collection, conversion diagnostics, channel chain, billing accumulators) survives - relay: Claude channel now serves Gemini-format clients (request via registry conversion, response and stream composed through the Chat pivot), removing the last unimplemented conversion direction - relaykit: recognize legacy pseudo tool names (googleSearch, codeExecution, urlContext) in the toolconv decode stage and drop the string-matching bypass in the Chat-to-Gemini converter; native Gemini tool output is restored and non-Gemini targets follow standard loss diagnostics - relaykit: attach upstream Gemini usage (with billing_usage sidecar) to intermediate stream chunks so converted Claude streams report upstream truth from message_start, and preserve the sidecar through Claude stream usage merges; billing settlement unchanged - billing: clamp negative Total-Prompt completion derivation, OR the Estimated flag across cross-dialect snapshot replacement, and fill canonical OpenAI prompt details via field-wise merge
This commit is contained in:
@@ -270,25 +270,12 @@ func (usage *BillingUsage) CanonicalUsage() (*Usage, bool) {
|
||||
|
||||
func (usage *BillingUsage) canonicalOpenAIUsage() *Usage {
|
||||
canonical := cloneOpenAIUsage(usage.OpenAIUsage)
|
||||
if inputDetails := canonical.InputTokensDetails; inputDetails != nil {
|
||||
if canonical.PromptTokensDetails.CachedTokens == 0 && inputDetails.CachedTokens > 0 {
|
||||
canonical.PromptTokensDetails.CachedTokens = inputDetails.CachedTokens
|
||||
}
|
||||
if canonical.PromptTokensDetails.CachedCreationTokens == 0 && inputDetails.CachedCreationTokens > 0 {
|
||||
canonical.PromptTokensDetails.CachedCreationTokens = inputDetails.CachedCreationTokens
|
||||
}
|
||||
if canonical.PromptTokensDetails.CacheWriteTokens == 0 && inputDetails.CacheWriteTokens > 0 {
|
||||
canonical.PromptTokensDetails.CacheWriteTokens = inputDetails.CacheWriteTokens
|
||||
}
|
||||
if canonical.PromptTokensDetails.TextTokens == 0 && inputDetails.TextTokens > 0 {
|
||||
canonical.PromptTokensDetails.TextTokens = inputDetails.TextTokens
|
||||
}
|
||||
if canonical.PromptTokensDetails.ImageTokens == 0 && inputDetails.ImageTokens > 0 {
|
||||
canonical.PromptTokensDetails.ImageTokens = inputDetails.ImageTokens
|
||||
}
|
||||
if canonical.PromptTokensDetails.AudioTokens == 0 && inputDetails.AudioTokens > 0 {
|
||||
canonical.PromptTokensDetails.AudioTokens = inputDetails.AudioTokens
|
||||
}
|
||||
if canonical.InputTokensDetails != nil {
|
||||
// InputTokensDetails fills fields that PromptTokensDetails omitted;
|
||||
// existing PromptTokensDetails values stay canonical on overlap.
|
||||
filled := *canonical.InputTokensDetails
|
||||
mergeInputTokenDetails(&filled, canonical.PromptTokensDetails)
|
||||
canonical.PromptTokensDetails = filled
|
||||
}
|
||||
if canonical.PromptTokensDetails.CachedTokens == 0 && canonical.PromptCacheHitTokens > 0 {
|
||||
canonical.PromptTokensDetails.CachedTokens = canonical.PromptCacheHitTokens
|
||||
@@ -316,12 +303,15 @@ func (usage *BillingUsage) canonicalOpenAIUsage() *Usage {
|
||||
|
||||
func (usage *BillingUsage) canonicalClaudeUsage() *Usage {
|
||||
claudeUsage := usage.ClaudeUsage
|
||||
cacheCreation5m := claudeUsage.GetCacheCreation5mTokens()
|
||||
if cacheCreation5m == 0 {
|
||||
// Flat legacy fields are a fallback only when this snapshot never carried
|
||||
// a CacheCreation sub-object. Presence (non-nil), not zero vs non-zero,
|
||||
// is the discriminator — a later sub-object that zeros 1h must win.
|
||||
var cacheCreation5m, cacheCreation1h int
|
||||
if claudeUsage.CacheCreation != nil {
|
||||
cacheCreation5m = claudeUsage.GetCacheCreation5mTokens()
|
||||
cacheCreation1h = claudeUsage.GetCacheCreation1hTokens()
|
||||
} else {
|
||||
cacheCreation5m = claudeUsage.ClaudeCacheCreation5mTokens
|
||||
}
|
||||
cacheCreation1h := claudeUsage.GetCacheCreation1hTokens()
|
||||
if cacheCreation1h == 0 {
|
||||
cacheCreation1h = claudeUsage.ClaudeCacheCreation1hTokens
|
||||
}
|
||||
|
||||
@@ -363,7 +353,7 @@ func (usage *BillingUsage) canonicalGeminiUsage() *Usage {
|
||||
addGeminiInputTokenDetail(&canonical.PromptTokensDetails, detail)
|
||||
}
|
||||
for _, detail := range metadata.CandidatesTokensDetails {
|
||||
switch detail.Modality {
|
||||
switch normalizeGeminiModality(detail.Modality) {
|
||||
case "IMAGE":
|
||||
canonical.CompletionTokenDetails.ImageTokens += detail.TokenCount
|
||||
case "AUDIO":
|
||||
@@ -377,6 +367,9 @@ func (usage *BillingUsage) canonicalGeminiUsage() *Usage {
|
||||
canonical.TotalTokens = canonical.PromptTokens + canonical.CompletionTokens
|
||||
} else if canonical.CompletionTokens <= 0 {
|
||||
canonical.CompletionTokens = canonical.TotalTokens - canonical.PromptTokens
|
||||
if canonical.CompletionTokens < 0 {
|
||||
canonical.CompletionTokens = 0
|
||||
}
|
||||
}
|
||||
if canonical.PromptTokens > 0 && canonical.PromptTokensDetails.TextTokens == 0 && canonical.PromptTokensDetails.AudioTokens == 0 {
|
||||
canonical.PromptTokensDetails.TextTokens = canonical.PromptTokens
|
||||
@@ -385,7 +378,7 @@ func (usage *BillingUsage) canonicalGeminiUsage() *Usage {
|
||||
}
|
||||
|
||||
func addGeminiInputTokenDetail(details *InputTokenDetails, detail GeminiPromptTokensDetails) {
|
||||
switch detail.Modality {
|
||||
switch normalizeGeminiModality(detail.Modality) {
|
||||
case "AUDIO":
|
||||
details.AudioTokens += detail.TokenCount
|
||||
case "IMAGE":
|
||||
|
||||
@@ -63,6 +63,38 @@ func TestNewEstimatedGeminiChatBillingUsage(t *testing.T) {
|
||||
assert.Equal(t, 18, billingUsage.GeminiUsageMetadata.TotalTokenCount)
|
||||
}
|
||||
|
||||
func TestCanonicalGeminiUsageClampsNegativeCompletionFromTotalMinusPrompt(t *testing.T) {
|
||||
usage, ok := NewGeminiChatBillingUsage(&GeminiUsageMetadata{
|
||||
PromptTokenCount: 50,
|
||||
TotalTokenCount: 30,
|
||||
}).CanonicalUsage()
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, 0, usage.CompletionTokens)
|
||||
}
|
||||
|
||||
func TestCanonicalOpenAIUsageMergesInputTokenDetailsFieldwise(t *testing.T) {
|
||||
usage, ok := NewOpenAIResponsesBillingUsage(&Usage{
|
||||
PromptTokens: 10,
|
||||
PromptTokensDetails: InputTokenDetails{
|
||||
CachedTokens: 8,
|
||||
TextTokens: 12,
|
||||
ImageTokens: 4,
|
||||
AudioTokens: 3,
|
||||
},
|
||||
InputTokensDetails: &InputTokenDetails{
|
||||
CachedTokens: 5,
|
||||
CachedCreationTokens: 7,
|
||||
TextTokens: 2,
|
||||
},
|
||||
}).CanonicalUsage()
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, 8, usage.PromptTokensDetails.CachedTokens)
|
||||
assert.Equal(t, 12, usage.PromptTokensDetails.TextTokens)
|
||||
assert.Equal(t, 4, usage.PromptTokensDetails.ImageTokens)
|
||||
assert.Equal(t, 3, usage.PromptTokensDetails.AudioTokens)
|
||||
assert.Equal(t, 7, usage.PromptTokensDetails.CachedCreationTokens)
|
||||
}
|
||||
|
||||
func TestBillingUsageJSONUsesProtocolNamedFields(t *testing.T) {
|
||||
billingUsage := &BillingUsage{
|
||||
OpenAIUsage: &Usage{PromptTokens: 1, BillingUsage: NewClaudeMessagesBillingUsage(&ClaudeUsage{InputTokens: 9})},
|
||||
|
||||
@@ -101,7 +101,13 @@ func MergeBillingUsageNonZero(current *BillingUsage, incoming *BillingUsage) *Bi
|
||||
return CloneBillingUsage(current)
|
||||
}
|
||||
if current == nil || !sameBillingUsageDialect(current, incoming) {
|
||||
return CloneBillingUsage(incoming)
|
||||
replaced := CloneBillingUsage(incoming)
|
||||
if current != nil && replaced != nil {
|
||||
// Replacement carries the incoming payload; Estimated carries the
|
||||
// history of any local synthesis on either side.
|
||||
replaced.Estimated = current.Estimated || incoming.Estimated
|
||||
}
|
||||
return replaced
|
||||
}
|
||||
|
||||
merged := CloneBillingUsage(current)
|
||||
@@ -120,7 +126,7 @@ func MergeBillingUsageNonZero(current *BillingUsage, incoming *BillingUsage) *Bi
|
||||
cloneOpenAIUsage(incoming.OpenAIUsage),
|
||||
)
|
||||
case current.ClaudeUsage != nil && incoming.ClaudeUsage != nil:
|
||||
merged.ClaudeUsage = mergeClaudeUsageNonZero(current.ClaudeUsage, incoming.ClaudeUsage)
|
||||
merged.ClaudeUsage = MergeClaudeUsageNonZero(current.ClaudeUsage, incoming.ClaudeUsage)
|
||||
case current.GeminiUsageMetadata != nil && incoming.GeminiUsageMetadata != nil:
|
||||
merged.GeminiUsageMetadata = MergeGeminiUsageMetadataNonZero(current.GeminiUsageMetadata, incoming.GeminiUsageMetadata)
|
||||
}
|
||||
@@ -140,12 +146,15 @@ func sameBillingUsageDialect(current *BillingUsage, incoming *BillingUsage) bool
|
||||
current.GeminiUsageMetadata != nil && incoming.GeminiUsageMetadata != nil
|
||||
}
|
||||
|
||||
func mergeClaudeUsageNonZero(current *ClaudeUsage, incoming *ClaudeUsage) *ClaudeUsage {
|
||||
func MergeClaudeUsageNonZero(current *ClaudeUsage, incoming *ClaudeUsage) *ClaudeUsage {
|
||||
merged := cloneClaudeUsage(current)
|
||||
if merged == nil {
|
||||
merged = &ClaudeUsage{}
|
||||
}
|
||||
if incoming == nil {
|
||||
if current != nil {
|
||||
merged.BillingUsage = CloneBillingUsage(current.BillingUsage)
|
||||
}
|
||||
return merged
|
||||
}
|
||||
if incoming.InputTokens > 0 {
|
||||
@@ -169,6 +178,11 @@ func mergeClaudeUsageNonZero(current *ClaudeUsage, incoming *ClaudeUsage) *Claud
|
||||
if incoming.CacheCreation != nil {
|
||||
cacheCreation := *incoming.CacheCreation
|
||||
merged.CacheCreation = &cacheCreation
|
||||
// Flat legacy fields are the same information as the sub-object.
|
||||
// Sync them as a whole overwrite, including explicit zeros, so a
|
||||
// later correction cannot leave a stale high-watermark behind.
|
||||
merged.ClaudeCacheCreation5mTokens = cacheCreation.Ephemeral5mInputTokens
|
||||
merged.ClaudeCacheCreation1hTokens = cacheCreation.Ephemeral1hInputTokens
|
||||
}
|
||||
if incoming.ServerToolUse != nil {
|
||||
if merged.ServerToolUse == nil {
|
||||
@@ -187,6 +201,14 @@ func mergeClaudeUsageNonZero(current *ClaudeUsage, incoming *ClaudeUsage) *Claud
|
||||
merged.ServerToolUse.ToolSearchRequests = incoming.ServerToolUse.ToolSearchRequests
|
||||
}
|
||||
}
|
||||
// cloneClaudeUsage strips BillingUsage so a nested Claude snapshot cannot
|
||||
// recurse. Restore the client-visible sidecar here: incoming wins when
|
||||
// present (authoritative/upstream), otherwise keep current's.
|
||||
if incoming.BillingUsage != nil {
|
||||
merged.BillingUsage = CloneBillingUsage(incoming.BillingUsage)
|
||||
} else if current != nil {
|
||||
merged.BillingUsage = CloneBillingUsage(current.BillingUsage)
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
@@ -238,19 +260,23 @@ func MergeGeminiUsageMetadataNonZero(current *GeminiUsageMetadata, incoming *Gem
|
||||
return &merged
|
||||
}
|
||||
|
||||
func normalizeGeminiModality(modality string) string {
|
||||
return strings.ToUpper(strings.TrimSpace(modality))
|
||||
}
|
||||
|
||||
func mergeGeminiTokenDetails(current []GeminiPromptTokensDetails, incoming []GeminiPromptTokensDetails) []GeminiPromptTokensDetails {
|
||||
merged := append([]GeminiPromptTokensDetails{}, current...)
|
||||
indexes := make(map[string]int, len(merged))
|
||||
for index, detail := range merged {
|
||||
indexes[strings.ToUpper(strings.TrimSpace(detail.Modality))] = index
|
||||
indexes[normalizeGeminiModality(detail.Modality)] = index
|
||||
}
|
||||
for _, detail := range incoming {
|
||||
if detail.TokenCount <= 0 {
|
||||
continue
|
||||
}
|
||||
key := strings.ToUpper(strings.TrimSpace(detail.Modality))
|
||||
key := normalizeGeminiModality(detail.Modality)
|
||||
if index, ok := indexes[key]; ok {
|
||||
merged[index] = detail
|
||||
merged[index].TokenCount += detail.TokenCount
|
||||
continue
|
||||
}
|
||||
indexes[key] = len(merged)
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
func TestMergeClaudeUsageCacheCreationReplacesWholeObject(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
merged := mergeClaudeUsageNonZero(
|
||||
merged := MergeClaudeUsageNonZero(
|
||||
&ClaudeUsage{
|
||||
CacheCreation: &ClaudeCacheCreationUsage{Ephemeral1hInputTokens: 1000},
|
||||
},
|
||||
@@ -52,6 +52,173 @@ func TestMergeGeminiUsageMetadataCandidatesAndThoughtsReplacedAsPair(t *testing.
|
||||
assert.Equal(t, 150, usage.CompletionTokens)
|
||||
}
|
||||
|
||||
func TestGeminiModalityKeysSettleConsistentlyAndDuplicateEntriesSum(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, modality := range []string{"audio", " AUDIO ", "AUDIO"} {
|
||||
t.Run("settle_"+modality, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
billing := NewGeminiChatBillingUsage(&GeminiUsageMetadata{
|
||||
PromptTokenCount: 100,
|
||||
PromptTokensDetails: []GeminiPromptTokensDetails{
|
||||
{Modality: modality, TokenCount: 40},
|
||||
{Modality: "TEXT", TokenCount: 60},
|
||||
},
|
||||
})
|
||||
usage, ok := billing.CanonicalUsage()
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, 40, usage.PromptTokensDetails.AudioTokens)
|
||||
assert.Equal(t, 60, usage.PromptTokensDetails.TextTokens)
|
||||
})
|
||||
}
|
||||
|
||||
mergedDetails := mergeGeminiTokenDetails(
|
||||
[]GeminiPromptTokensDetails{{Modality: "AUDIO", TokenCount: 10}},
|
||||
[]GeminiPromptTokensDetails{{Modality: "audio", TokenCount: 15}},
|
||||
)
|
||||
require.Len(t, mergedDetails, 1)
|
||||
assert.Equal(t, 25, mergedDetails[0].TokenCount)
|
||||
|
||||
streamMerged := MergeGeminiUsageMetadataNonZero(
|
||||
&GeminiUsageMetadata{
|
||||
PromptTokenCount: 10,
|
||||
PromptTokensDetails: []GeminiPromptTokensDetails{{Modality: "AUDIO", TokenCount: 10}},
|
||||
},
|
||||
&GeminiUsageMetadata{
|
||||
PromptTokenCount: 25,
|
||||
PromptTokensDetails: []GeminiPromptTokensDetails{{Modality: "audio", TokenCount: 15}},
|
||||
},
|
||||
)
|
||||
require.NotNil(t, streamMerged)
|
||||
streamUsage, ok := NewGeminiChatBillingUsage(streamMerged).CanonicalUsage()
|
||||
require.True(t, ok)
|
||||
|
||||
decodedUsage, ok := NewGeminiChatBillingUsage(&GeminiUsageMetadata{
|
||||
PromptTokenCount: 25,
|
||||
PromptTokensDetails: []GeminiPromptTokensDetails{
|
||||
{Modality: "AUDIO", TokenCount: 10},
|
||||
{Modality: "audio", TokenCount: 15},
|
||||
},
|
||||
}).CanonicalUsage()
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, decodedUsage.PromptTokensDetails.AudioTokens, streamUsage.PromptTokensDetails.AudioTokens)
|
||||
assert.Equal(t, 25, decodedUsage.PromptTokensDetails.AudioTokens)
|
||||
}
|
||||
|
||||
func TestClaudeCacheCreationSubObjectZeroDoesNotReviveFlatLegacyFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
merged := MergeClaudeUsageNonZero(
|
||||
&ClaudeUsage{ClaudeCacheCreation1hTokens: 1000},
|
||||
&ClaudeUsage{
|
||||
InputTokens: 10,
|
||||
CacheCreation: &ClaudeCacheCreationUsage{
|
||||
Ephemeral5mInputTokens: 1000,
|
||||
Ephemeral1hInputTokens: 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
require.NotNil(t, merged.CacheCreation)
|
||||
assert.Equal(t, 1000, merged.CacheCreation.Ephemeral5mInputTokens)
|
||||
assert.Equal(t, 0, merged.CacheCreation.Ephemeral1hInputTokens)
|
||||
assert.Equal(t, 1000, merged.ClaudeCacheCreation5mTokens)
|
||||
assert.Equal(t, 0, merged.ClaudeCacheCreation1hTokens)
|
||||
|
||||
usage, ok := NewClaudeMessagesBillingUsage(merged).CanonicalUsage()
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, 1000, usage.ClaudeCacheCreation5mTokens)
|
||||
assert.Equal(t, 0, usage.ClaudeCacheCreation1hTokens)
|
||||
}
|
||||
|
||||
func TestClaudeCacheCreationFlatFieldsStillSettleWhenSnapshotNeverHadSubObject(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
usage, ok := NewClaudeMessagesBillingUsage(&ClaudeUsage{
|
||||
InputTokens: 10,
|
||||
ClaudeCacheCreation1hTokens: 1000,
|
||||
}).CanonicalUsage()
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, 0, usage.ClaudeCacheCreation5mTokens)
|
||||
assert.Equal(t, 1000, usage.ClaudeCacheCreation1hTokens)
|
||||
}
|
||||
|
||||
func TestMergeBillingUsageORsEstimatedOnSameAndCrossDialect(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
estimated := NewEstimatedGeminiChatBillingUsage(&Usage{PromptTokens: 10, CompletionTokens: 2})
|
||||
require.NotNil(t, estimated)
|
||||
require.True(t, estimated.Estimated)
|
||||
|
||||
sameDialect := MergeBillingUsageNonZero(estimated, NewGeminiChatBillingUsage(&GeminiUsageMetadata{
|
||||
PromptTokenCount: 11,
|
||||
CandidatesTokenCount: 3,
|
||||
TotalTokenCount: 14,
|
||||
}))
|
||||
require.NotNil(t, sameDialect)
|
||||
assert.True(t, sameDialect.Estimated)
|
||||
|
||||
crossDialect := MergeBillingUsageNonZero(estimated, NewOpenAIChatBillingUsage(&Usage{
|
||||
PromptTokens: 12,
|
||||
CompletionTokens: 4,
|
||||
TotalTokens: 16,
|
||||
}))
|
||||
require.NotNil(t, crossDialect)
|
||||
assert.True(t, crossDialect.Estimated)
|
||||
require.NotNil(t, crossDialect.OpenAIUsage)
|
||||
assert.Equal(t, 12, crossDialect.OpenAIUsage.PromptTokens)
|
||||
}
|
||||
|
||||
func TestMergeClaudeUsageNonZeroPreservesBillingUsage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
currentSidecar := NewGeminiChatBillingUsage(&GeminiUsageMetadata{
|
||||
PromptTokenCount: 3868,
|
||||
TotalTokenCount: 3868,
|
||||
CachedContentTokenCount: 20,
|
||||
})
|
||||
incomingSidecar := NewGeminiChatBillingUsage(&GeminiUsageMetadata{
|
||||
PromptTokenCount: 3868,
|
||||
CandidatesTokenCount: 12,
|
||||
TotalTokenCount: 3880,
|
||||
})
|
||||
require.NotNil(t, currentSidecar)
|
||||
require.NotNil(t, incomingSidecar)
|
||||
|
||||
withIncoming := MergeClaudeUsageNonZero(
|
||||
&ClaudeUsage{
|
||||
InputTokens: 3868,
|
||||
CacheReadInputTokens: 20,
|
||||
BillingUsage: currentSidecar,
|
||||
},
|
||||
&ClaudeUsage{
|
||||
InputTokens: 3868,
|
||||
OutputTokens: 12,
|
||||
BillingUsage: incomingSidecar,
|
||||
},
|
||||
)
|
||||
require.NotNil(t, withIncoming.BillingUsage)
|
||||
assert.Equal(t, BillingUsageSourceGeminiChat, withIncoming.BillingUsage.Source)
|
||||
assert.Equal(t, BillingUsageSemanticGemini, withIncoming.BillingUsage.Semantic)
|
||||
require.NotNil(t, withIncoming.BillingUsage.GeminiUsageMetadata)
|
||||
assert.Equal(t, 12, withIncoming.BillingUsage.GeminiUsageMetadata.CandidatesTokenCount)
|
||||
assert.Equal(t, 20, withIncoming.CacheReadInputTokens)
|
||||
assert.NotSame(t, incomingSidecar, withIncoming.BillingUsage)
|
||||
|
||||
keepCurrent := MergeClaudeUsageNonZero(
|
||||
&ClaudeUsage{
|
||||
InputTokens: 3868,
|
||||
CacheReadInputTokens: 20,
|
||||
BillingUsage: currentSidecar,
|
||||
},
|
||||
&ClaudeUsage{InputTokens: 3868, OutputTokens: 12},
|
||||
)
|
||||
require.NotNil(t, keepCurrent.BillingUsage)
|
||||
require.NotNil(t, keepCurrent.BillingUsage.GeminiUsageMetadata)
|
||||
assert.Equal(t, 20, keepCurrent.BillingUsage.GeminiUsageMetadata.CachedContentTokenCount)
|
||||
assert.Equal(t, 0, keepCurrent.BillingUsage.GeminiUsageMetadata.CandidatesTokenCount)
|
||||
assert.Equal(t, 20, keepCurrent.CacheReadInputTokens)
|
||||
}
|
||||
|
||||
func TestMergeUsageNonZeroKeepsPositiveValuesAndTakesMaxTotal(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user