Files
new-api/relaykit/dto/usage_merge_test.go
T
Calcium-Ion bbd97446c2 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
2026-09-03 10:40:05 +08:00

235 lines
7.3 KiB
Go

package dto
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMergeClaudeUsageCacheCreationReplacesWholeObject(t *testing.T) {
t.Parallel()
merged := MergeClaudeUsageNonZero(
&ClaudeUsage{
CacheCreation: &ClaudeCacheCreationUsage{Ephemeral1hInputTokens: 1000},
},
&ClaudeUsage{
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)
}
func TestMergeGeminiUsageMetadataCandidatesAndThoughtsReplacedAsPair(t *testing.T) {
t.Parallel()
merged := MergeGeminiUsageMetadataNonZero(
&GeminiUsageMetadata{
PromptTokenCount: 10,
ThoughtsTokenCount: 100,
},
&GeminiUsageMetadata{
PromptTokenCount: 10,
CandidatesTokenCount: 150,
ThoughtsTokenCount: 0,
TotalTokenCount: 160,
},
)
require.NotNil(t, merged)
assert.Equal(t, 150, merged.CandidatesTokenCount)
assert.Equal(t, 0, merged.ThoughtsTokenCount)
billing := NewGeminiChatBillingUsage(merged)
usage, ok := billing.CanonicalUsage()
require.True(t, ok)
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()
merged := MergeUsageNonZero(
&Usage{PromptTokens: 10, CompletionTokens: 5, TotalTokens: 15},
&Usage{PromptTokens: 0, CompletionTokens: 0, TotalTokens: 20},
)
require.NotNil(t, merged)
assert.Equal(t, 10, merged.PromptTokens)
assert.Equal(t, 5, merged.CompletionTokens)
assert.Equal(t, 20, merged.TotalTokens)
}