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:
Calcium-Ion
2026-09-03 10:40:05 +08:00
committed by GitHub
parent 0ed497f066
commit bbd97446c2
18 changed files with 958 additions and 103 deletions
+19 -26
View File
@@ -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":
+32
View File
@@ -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})},
+32 -6
View File
@@ -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)
+168 -1
View File
@@ -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()
@@ -283,6 +283,11 @@ func StreamResponseGeminiChat2OpenAI(geminiResponse *dto.GeminiChatResponse) (*d
Object: "chat.completion.chunk",
Choices: choices,
}
// Only attach usage the chunk actually reported. Do not fall back to a
// local prompt estimate — converters treat this as first-frame truth.
if metadata := geminiResponse.GetUsageMetadata(); dto.HasGeminiUsageMetadataTokens(metadata) {
response.Usage = UsageFromGeminiMetadata(metadata, 0)
}
return &response, isStop
}
@@ -89,6 +89,21 @@ func buildClaudeUsageFromOpenAIUsage(oaiUsage *dto.Usage) *dto.ClaudeUsage {
return sharedclaude.UsageFromOpenAI(oaiUsage)
}
func clientVisibleClaudeStreamUsage(state *convmeta.ClaudeConvertInfo, incoming *dto.Usage) *dto.ClaudeUsage {
prior := buildClaudeUsageFromOpenAIUsage(state.Usage)
converted := buildClaudeUsageFromOpenAIUsage(incoming)
if incoming != nil {
state.Usage = dto.MergeUsageNonZero(state.Usage, incoming)
}
if prior == nil {
return converted
}
if converted == nil {
return prior
}
return dto.MergeClaudeUsageNonZero(prior, converted)
}
func NormalizeCacheCreationSplit(totalTokens int, tokens5m int, tokens1h int) (int, int) {
return sharedclaude.NormalizeCacheCreationSplit(totalTokens, tokens5m, tokens1h)
}
@@ -168,15 +183,27 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
}
}
if info.GetSendResponseCount() == 1 {
// Client-visible Claude stream usage matches billing merge: first
// frame records first, a later non-zero field overrides, and a later
// zero/missing field never erases a first-frame positive. Anthropic
// clients treat message_delta as authoritative for the fields it
// carries, including a corrected input_tokens.
startUsage := &dto.ClaudeUsage{
InputTokens: info.GetEstimatePromptTokens(),
OutputTokens: 0,
}
if openAIResponse.Usage != nil && dto.HasOpenAIUsageTokens(openAIResponse.Usage) {
if real := buildClaudeUsageFromOpenAIUsage(openAIResponse.Usage); real != nil {
startUsage = real
}
state.Usage = dto.MergeUsageNonZero(state.Usage, openAIResponse.Usage)
}
msg := &dto.ClaudeMediaMessage{
Id: openAIResponse.Id,
Model: openAIResponse.Model,
Type: "message",
Role: "assistant",
Usage: &dto.ClaudeUsage{
InputTokens: info.GetEstimatePromptTokens(),
OutputTokens: 0,
},
Usage: startUsage,
}
msg.SetContent(make([]any, 0))
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
@@ -187,10 +214,7 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
if len(openAIResponse.Choices) == 0 {
// Some OpenAI-compatible upstreams end with a usage-only SSE chunk.
oaiUsage := openAIResponse.Usage
if oaiUsage == nil {
oaiUsage = state.Usage
}
oaiUsage := clientVisibleClaudeStreamUsage(state, openAIResponse.Usage)
if oaiUsage != nil {
appendStopOpenBlocks()
stopReason := stopReasonOpenAI2Claude(state.FinishReason)
@@ -199,7 +223,7 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
}
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_delta",
Usage: buildClaudeUsageFromOpenAIUsage(oaiUsage),
Usage: oaiUsage,
Delta: &dto.ClaudeMediaMessage{
StopReason: kitutil.GetPointer[string](stopReason),
},
@@ -367,10 +391,7 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
appendCitationDeltas(chosenChoice.Delta.Annotations)
if doneChunk || state.Done {
oaiUsage := openAIResponse.Usage
if oaiUsage == nil {
oaiUsage = state.Usage
}
oaiUsage := clientVisibleClaudeStreamUsage(state, openAIResponse.Usage)
if oaiUsage == nil {
// Some upstreams emit finish_reason first, then send a final usage-only chunk.
// Keep content blocks open until usage is available so the terminal message_delta
@@ -380,7 +401,7 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
appendStopOpenBlocks()
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_delta",
Usage: buildClaudeUsageFromOpenAIUsage(oaiUsage),
Usage: oaiUsage,
Delta: &dto.ClaudeMediaMessage{
StopReason: kitutil.GetPointer[string](stopReasonOpenAI2Claude(state.FinishReason)),
},
@@ -414,7 +435,7 @@ func FinalizeStreamResponseOpenAI2Claude(info convmeta.Meta) []*dto.ClaudeRespon
responses = append(responses,
&dto.ClaudeResponse{
Type: "message_delta",
Usage: buildClaudeUsageFromOpenAIUsage(state.Usage),
Usage: clientVisibleClaudeStreamUsage(state, nil),
Delta: &dto.ClaudeMediaMessage{
StopReason: kitutil.GetPointer[string](stopReason),
},
@@ -223,6 +223,192 @@ func TestStreamResponseOpenAI2ClaudeClosesTextThinkingAndToolBlocks(t *testing.T
assert.Equal(t, "message_stop", finishResponses[2].Type)
}
func TestStreamResponseOpenAI2ClaudeFirstFrameUsesUpstreamUsageWhenPresent(t *testing.T) {
info := &convmeta.Values{
EstimatePromptTokens: 32,
SendResponseCount: 1,
ClaudeConvertInfo: &convmeta.ClaudeConvertInfo{LastMessagesType: convmeta.LastMessageTypeNone},
}
responses := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.ChatCompletionsStreamResponseChoice{{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: ptr("hello")},
}},
Usage: &dto.Usage{PromptTokens: 29, CompletionTokens: 0, TotalTokens: 29},
}, info)
require.NotEmpty(t, responses)
require.Equal(t, "message_start", responses[0].Type)
require.NotNil(t, responses[0].Message)
require.NotNil(t, responses[0].Message.Usage)
assert.Equal(t, 29, responses[0].Message.Usage.InputTokens)
}
func TestStreamResponseOpenAI2ClaudeMessageDeltaCorrectsEstimatedFirstFrame(t *testing.T) {
info := &convmeta.Values{
EstimatePromptTokens: 32,
SendResponseCount: 1,
ClaudeConvertInfo: &convmeta.ClaudeConvertInfo{LastMessagesType: convmeta.LastMessageTypeNone},
}
first := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.ChatCompletionsStreamResponseChoice{{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: ptr("hello")},
}},
}, info)
require.NotEmpty(t, first)
require.Equal(t, "message_start", first[0].Type)
require.NotNil(t, first[0].Message.Usage)
assert.Equal(t, 32, first[0].Message.Usage.InputTokens)
info.SendResponseCount = 2
finish := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.ChatCompletionsStreamResponseChoice{{
FinishReason: ptr("stop"),
}},
Usage: &dto.Usage{PromptTokens: 29, CompletionTokens: 4, TotalTokens: 33},
}, info)
var delta *dto.ClaudeResponse
for _, resp := range finish {
if resp.Type == "message_delta" {
delta = resp
break
}
}
require.NotNil(t, delta)
require.NotNil(t, delta.Usage)
assert.Equal(t, 29, delta.Usage.InputTokens)
assert.Equal(t, 4, delta.Usage.OutputTokens)
}
func TestStreamResponseOpenAI2ClaudeMessageDeltaDoesNotZeroFirstFrameCache(t *testing.T) {
info := &convmeta.Values{
EstimatePromptTokens: 8,
SendResponseCount: 1,
ClaudeConvertInfo: &convmeta.ClaudeConvertInfo{LastMessagesType: convmeta.LastMessageTypeNone},
}
first := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.ChatCompletionsStreamResponseChoice{{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: ptr("hello")},
}},
Usage: &dto.Usage{
PromptTokens: 40,
CompletionTokens: 0,
TotalTokens: 40,
PromptTokensDetails: dto.InputTokenDetails{
CachedTokens: 20,
CachedCreationTokens: 10,
},
},
}, info)
require.Equal(t, "message_start", first[0].Type)
require.NotNil(t, first[0].Message.Usage)
assert.Equal(t, 20, first[0].Message.Usage.CacheReadInputTokens)
assert.Equal(t, 10, first[0].Message.Usage.CacheCreationInputTokens)
info.SendResponseCount = 2
finish := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.ChatCompletionsStreamResponseChoice{{
FinishReason: ptr("stop"),
}},
Usage: &dto.Usage{PromptTokens: 29, CompletionTokens: 4, TotalTokens: 33},
}, info)
var delta *dto.ClaudeResponse
for _, resp := range finish {
if resp.Type == "message_delta" {
delta = resp
break
}
}
require.NotNil(t, delta)
require.NotNil(t, delta.Usage)
assert.Equal(t, 29, delta.Usage.InputTokens)
assert.Equal(t, 20, delta.Usage.CacheReadInputTokens)
assert.Equal(t, 10, delta.Usage.CacheCreationInputTokens)
}
func TestStreamResponseOpenAI2ClaudeGeminiBillingUsageOnStartAndDelta(t *testing.T) {
info := &convmeta.Values{
EstimatePromptTokens: 4994,
SendResponseCount: 1,
ClaudeConvertInfo: &convmeta.ClaudeConvertInfo{LastMessagesType: convmeta.LastMessageTypeNone},
}
firstUsage := &dto.Usage{
PromptTokens: 3868,
CompletionTokens: 0,
TotalTokens: 3868,
BillingUsage: dto.NewGeminiChatBillingUsage(&dto.GeminiUsageMetadata{
PromptTokenCount: 3868,
TotalTokenCount: 3868,
}),
}
first := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.ChatCompletionsStreamResponseChoice{{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: ptr("hello")},
}},
Usage: firstUsage,
}, info)
require.NotEmpty(t, first)
require.Equal(t, "message_start", first[0].Type)
require.NotNil(t, first[0].Message)
require.NotNil(t, first[0].Message.Usage)
assert.Equal(t, 3868, first[0].Message.Usage.InputTokens)
require.NotNil(t, first[0].Message.Usage.BillingUsage)
assert.Equal(t, dto.BillingUsageSourceGeminiChat, first[0].Message.Usage.BillingUsage.Source)
assert.Equal(t, dto.BillingUsageSemanticGemini, first[0].Message.Usage.BillingUsage.Semantic)
require.NotNil(t, first[0].Message.Usage.BillingUsage.GeminiUsageMetadata)
assert.Equal(t, 3868, first[0].Message.Usage.BillingUsage.GeminiUsageMetadata.PromptTokenCount)
info.SendResponseCount = 2
finish := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.ChatCompletionsStreamResponseChoice{{
FinishReason: ptr("stop"),
}},
Usage: &dto.Usage{
PromptTokens: 3868,
CompletionTokens: 12,
TotalTokens: 3880,
BillingUsage: dto.NewGeminiChatBillingUsage(&dto.GeminiUsageMetadata{
PromptTokenCount: 3868,
CandidatesTokenCount: 12,
TotalTokenCount: 3880,
}),
},
}, info)
var delta *dto.ClaudeResponse
for _, resp := range finish {
if resp.Type == "message_delta" {
delta = resp
break
}
}
require.NotNil(t, delta)
require.NotNil(t, delta.Usage)
assert.Equal(t, 3868, delta.Usage.InputTokens)
assert.Equal(t, 12, delta.Usage.OutputTokens)
require.NotNil(t, delta.Usage.BillingUsage)
assert.Equal(t, dto.BillingUsageSourceGeminiChat, delta.Usage.BillingUsage.Source)
assert.Equal(t, dto.BillingUsageSemanticGemini, delta.Usage.BillingUsage.Semantic)
require.NotNil(t, delta.Usage.BillingUsage.GeminiUsageMetadata)
assert.Equal(t, 3868, delta.Usage.BillingUsage.GeminiUsageMetadata.PromptTokenCount)
assert.Equal(t, 12, delta.Usage.BillingUsage.GeminiUsageMetadata.CandidatesTokenCount)
}
func TestNormalizeCacheCreationSplit(t *testing.T) {
cache5m, cache1h := NormalizeCacheCreationSplit(10, 3, 2)
assert.Equal(t, 8, cache5m)
@@ -157,22 +157,7 @@ func OpenAIChatRequestToGeminiGenerateContent(c context.Context, textRequest dto
if textRequest.Tools != nil {
functions := make([]dto.FunctionRequest, 0, len(textRequest.Tools))
googleSearch := false
codeExecution := false
urlContext := false
for _, tool := range textRequest.Tools {
if tool.Function.Name == "googleSearch" {
googleSearch = true
continue
}
if tool.Function.Name == "codeExecution" {
codeExecution = true
continue
}
if tool.Function.Name == "urlContext" {
urlContext = true
continue
}
if tool.Function.Parameters != nil {
if params, ok := tool.Function.Parameters.(map[string]interface{}); ok {
if props, hasProps := params["properties"].(map[string]interface{}); hasProps && len(props) == 0 {
@@ -184,21 +169,6 @@ func OpenAIChatRequestToGeminiGenerateContent(c context.Context, textRequest dto
functions = append(functions, tool.Function)
}
geminiTools := geminiRequest.GetTools()
if codeExecution {
geminiTools = append(geminiTools, dto.GeminiChatTool{
CodeExecution: make(map[string]string),
})
}
if googleSearch {
geminiTools = append(geminiTools, dto.GeminiChatTool{
GoogleSearch: make(map[string]string),
})
}
if urlContext {
geminiTools = append(geminiTools, dto.GeminiChatTool{
URLContext: make(map[string]string),
})
}
if len(functions) > 0 {
geminiTools = append(geminiTools, dto.GeminiChatTool{
FunctionDeclarations: functions,
@@ -56,6 +56,10 @@ func extractOpenAIChatRequest(request any) (any, Set, error) {
}
for index, tool := range source.Tools {
if tool.Type == "function" || tool.Type == "" {
if definition, ok := decodeOpenAIChatPseudoHostedTool(tool.Function.Name); ok {
set.Definitions = append(set.Definitions, definition)
continue
}
set.Definitions = append(set.Definitions, Definition{
Kind: KindFunction,
Execution: ExecutionClient,
@@ -511,6 +515,40 @@ func rawBoolPointer(raw json.RawMessage) *bool {
return &value
}
// decodeOpenAIChatPseudoHostedTool recognizes the OpenAI Chat dialect that
// declares Gemini hosted tools as function definitions named googleSearch,
// codeExecution, or urlContext. The names are the historical public contract;
// recognition lives here so every target format goes through the same hosted
// ToolDefinition pipeline.
func decodeOpenAIChatPseudoHostedTool(name string) (Definition, bool) {
switch name {
case "googleSearch":
return Definition{
Kind: KindWebSearch,
Execution: ExecutionServer,
NativeType: "googleSearch",
Name: "googleSearch",
WebSearch: &WebSearch{},
}, true
case "codeExecution":
return Definition{
Kind: KindCodeExecution,
Execution: ExecutionServer,
NativeType: "codeExecution",
Name: "codeExecution",
}, true
case "urlContext":
return Definition{
Kind: KindURLContext,
Execution: ExecutionServer,
NativeType: "urlContext",
Name: "urlContext",
}, true
default:
return Definition{}, false
}
}
func decodeOpenAIChatLocation(raw json.RawMessage) (*ApproximateLocation, error) {
if len(raw) == 0 {
return nil, nil
@@ -381,13 +381,13 @@ func attachGeminiRequest(request any, set Set) (any, []types.ConversionDiagnosti
diagnostics = append(diagnostics, geminiWebSearchDiagnostics(index, definition.WebSearch)...)
}
case KindCodeExecution:
if set.Source == types.RelayFormatGemini {
if set.Source == types.RelayFormatGemini || definition.NativeType == "codeExecution" {
tools = append(tools, map[string]any{"codeExecution": map[string]any{}})
continue
}
diagnostics = append(diagnostics, semanticLoss(fmt.Sprintf("tools[%d]", index), "unverified_tool_mapping", "code execution semantics differ across providers"))
case KindURLContext:
if set.Source == types.RelayFormatGemini {
if set.Source == types.RelayFormatGemini || definition.NativeType == "urlContext" {
tools = append(tools, map[string]any{"urlContext": map[string]any{}})
continue
}