mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-11 06:30:21 +00:00
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
308 lines
11 KiB
Go
308 lines
11 KiB
Go
package dto
|
|
|
|
import (
|
|
"reflect"
|
|
"strings"
|
|
)
|
|
|
|
// MergeUsageNonZero overlays usage snapshots: a later non-zero field
|
|
// overwrites the current value, while a later zero value never erases an
|
|
// earlier positive count. Compatible BillingUsage snapshots follow the same
|
|
// rule within their provider-native payload.
|
|
func MergeUsageNonZero(current *Usage, incoming *Usage) *Usage {
|
|
if current == nil {
|
|
current = &Usage{}
|
|
}
|
|
if incoming == nil {
|
|
return current
|
|
}
|
|
|
|
if incoming.PromptTokens > 0 {
|
|
current.PromptTokens = incoming.PromptTokens
|
|
}
|
|
if incoming.CompletionTokens > 0 {
|
|
current.CompletionTokens = incoming.CompletionTokens
|
|
}
|
|
if incoming.TotalTokens > 0 {
|
|
current.TotalTokens = incoming.TotalTokens
|
|
}
|
|
if incoming.PromptCacheHitTokens > 0 {
|
|
current.PromptCacheHitTokens = incoming.PromptCacheHitTokens
|
|
}
|
|
if incoming.InputTokens > 0 {
|
|
current.InputTokens = incoming.InputTokens
|
|
}
|
|
if incoming.OutputTokens > 0 {
|
|
current.OutputTokens = incoming.OutputTokens
|
|
}
|
|
if incoming.ClaudeCacheCreation5mTokens > 0 {
|
|
current.ClaudeCacheCreation5mTokens = incoming.ClaudeCacheCreation5mTokens
|
|
}
|
|
if incoming.ClaudeCacheCreation1hTokens > 0 {
|
|
current.ClaudeCacheCreation1hTokens = incoming.ClaudeCacheCreation1hTokens
|
|
}
|
|
|
|
mergeInputTokenDetails(¤t.PromptTokensDetails, incoming.PromptTokensDetails)
|
|
if incoming.InputTokensDetails != nil {
|
|
details := *incoming.InputTokensDetails
|
|
if details.CachedTokens > 0 ||
|
|
details.CachedCreationTokens > 0 ||
|
|
details.CacheWriteTokens > 0 ||
|
|
details.TextTokens > 0 ||
|
|
details.AudioTokens > 0 ||
|
|
details.ImageTokens > 0 {
|
|
if current.InputTokensDetails == nil {
|
|
current.InputTokensDetails = &InputTokenDetails{}
|
|
}
|
|
mergeInputTokenDetails(current.InputTokensDetails, details)
|
|
}
|
|
}
|
|
|
|
if incoming.CompletionTokenDetails.TextTokens > 0 {
|
|
current.CompletionTokenDetails.TextTokens = incoming.CompletionTokenDetails.TextTokens
|
|
}
|
|
if incoming.CompletionTokenDetails.AudioTokens > 0 {
|
|
current.CompletionTokenDetails.AudioTokens = incoming.CompletionTokenDetails.AudioTokens
|
|
}
|
|
if incoming.CompletionTokenDetails.ImageTokens > 0 {
|
|
current.CompletionTokenDetails.ImageTokens = incoming.CompletionTokenDetails.ImageTokens
|
|
}
|
|
if incoming.CompletionTokenDetails.ReasoningTokens > 0 {
|
|
current.CompletionTokenDetails.ReasoningTokens = incoming.CompletionTokenDetails.ReasoningTokens
|
|
}
|
|
|
|
if incoming.UsageSemantic != "" {
|
|
current.UsageSemantic = incoming.UsageSemantic
|
|
}
|
|
if incoming.UsageSource != "" {
|
|
current.UsageSource = incoming.UsageSource
|
|
}
|
|
if incoming.BillingUsage != nil {
|
|
current.BillingUsage = MergeBillingUsageNonZero(current.BillingUsage, incoming.BillingUsage)
|
|
}
|
|
if incoming.Cost != nil && !reflect.ValueOf(incoming.Cost).IsZero() {
|
|
current.Cost = incoming.Cost
|
|
}
|
|
if total := current.PromptTokens + current.CompletionTokens; total > current.TotalTokens {
|
|
current.TotalTokens = total
|
|
}
|
|
if total := current.InputTokens + current.OutputTokens; total > current.TotalTokens {
|
|
current.TotalTokens = total
|
|
}
|
|
|
|
return current
|
|
}
|
|
|
|
// MergeBillingUsageNonZero preserves non-zero provider-native fields across
|
|
// partial stream snapshots. A snapshot from a different billing dialect
|
|
// remains authoritative and replaces the previous payload.
|
|
func MergeBillingUsageNonZero(current *BillingUsage, incoming *BillingUsage) *BillingUsage {
|
|
if incoming == nil {
|
|
return CloneBillingUsage(current)
|
|
}
|
|
if current == nil || !sameBillingUsageDialect(current, 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)
|
|
if incoming.Source != "" {
|
|
merged.Source = incoming.Source
|
|
}
|
|
if incoming.Semantic != "" {
|
|
merged.Semantic = incoming.Semantic
|
|
}
|
|
merged.Estimated = current.Estimated || incoming.Estimated
|
|
|
|
switch {
|
|
case current.OpenAIUsage != nil && incoming.OpenAIUsage != nil:
|
|
merged.OpenAIUsage = MergeUsageNonZero(
|
|
cloneOpenAIUsage(current.OpenAIUsage),
|
|
cloneOpenAIUsage(incoming.OpenAIUsage),
|
|
)
|
|
case current.ClaudeUsage != nil && incoming.ClaudeUsage != nil:
|
|
merged.ClaudeUsage = MergeClaudeUsageNonZero(current.ClaudeUsage, incoming.ClaudeUsage)
|
|
case current.GeminiUsageMetadata != nil && incoming.GeminiUsageMetadata != nil:
|
|
merged.GeminiUsageMetadata = MergeGeminiUsageMetadataNonZero(current.GeminiUsageMetadata, incoming.GeminiUsageMetadata)
|
|
}
|
|
|
|
return merged
|
|
}
|
|
|
|
func sameBillingUsageDialect(current *BillingUsage, incoming *BillingUsage) bool {
|
|
if current.Source != "" && incoming.Source != "" && !strings.EqualFold(current.Source, incoming.Source) {
|
|
return false
|
|
}
|
|
if current.Semantic != "" && incoming.Semantic != "" && !strings.EqualFold(current.Semantic, incoming.Semantic) {
|
|
return false
|
|
}
|
|
return current.OpenAIUsage != nil && incoming.OpenAIUsage != nil ||
|
|
current.ClaudeUsage != nil && incoming.ClaudeUsage != nil ||
|
|
current.GeminiUsageMetadata != nil && incoming.GeminiUsageMetadata != nil
|
|
}
|
|
|
|
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 {
|
|
merged.InputTokens = incoming.InputTokens
|
|
}
|
|
if incoming.CacheCreationInputTokens > 0 {
|
|
merged.CacheCreationInputTokens = incoming.CacheCreationInputTokens
|
|
}
|
|
if incoming.CacheReadInputTokens > 0 {
|
|
merged.CacheReadInputTokens = incoming.CacheReadInputTokens
|
|
}
|
|
if incoming.OutputTokens > 0 {
|
|
merged.OutputTokens = incoming.OutputTokens
|
|
}
|
|
if incoming.ClaudeCacheCreation5mTokens > 0 {
|
|
merged.ClaudeCacheCreation5mTokens = incoming.ClaudeCacheCreation5mTokens
|
|
}
|
|
if incoming.ClaudeCacheCreation1hTokens > 0 {
|
|
merged.ClaudeCacheCreation1hTokens = incoming.ClaudeCacheCreation1hTokens
|
|
}
|
|
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 {
|
|
merged.ServerToolUse = &ClaudeServerToolUse{}
|
|
}
|
|
if incoming.ServerToolUse.WebSearchRequests > 0 {
|
|
merged.ServerToolUse.WebSearchRequests = incoming.ServerToolUse.WebSearchRequests
|
|
}
|
|
if incoming.ServerToolUse.WebFetchRequests > 0 {
|
|
merged.ServerToolUse.WebFetchRequests = incoming.ServerToolUse.WebFetchRequests
|
|
}
|
|
if incoming.ServerToolUse.CodeExecutionRequests > 0 {
|
|
merged.ServerToolUse.CodeExecutionRequests = incoming.ServerToolUse.CodeExecutionRequests
|
|
}
|
|
if incoming.ServerToolUse.ToolSearchRequests > 0 {
|
|
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
|
|
}
|
|
|
|
// MergeGeminiUsageMetadataNonZero overlays Gemini's cumulative usage
|
|
// snapshots: a later non-zero field overwrites the current value without
|
|
// dropping fields omitted by a later chunk.
|
|
func MergeGeminiUsageMetadataNonZero(current *GeminiUsageMetadata, incoming *GeminiUsageMetadata) *GeminiUsageMetadata {
|
|
if current == nil && incoming == nil {
|
|
return nil
|
|
}
|
|
if current == nil {
|
|
metadata := cloneGeminiUsageMetadata(*incoming)
|
|
metadata.BillingUsage = CloneBillingUsage(incoming.BillingUsage)
|
|
return &metadata
|
|
}
|
|
|
|
merged := cloneGeminiUsageMetadata(*current)
|
|
merged.BillingUsage = CloneBillingUsage(current.BillingUsage)
|
|
if incoming == nil {
|
|
return &merged
|
|
}
|
|
if incoming.PromptTokenCount > 0 {
|
|
merged.PromptTokenCount = incoming.PromptTokenCount
|
|
}
|
|
if incoming.ToolUsePromptTokenCount > 0 {
|
|
merged.ToolUsePromptTokenCount = incoming.ToolUsePromptTokenCount
|
|
}
|
|
if incoming.CandidatesTokenCount > 0 {
|
|
merged.CandidatesTokenCount = incoming.CandidatesTokenCount
|
|
merged.ThoughtsTokenCount = incoming.ThoughtsTokenCount
|
|
} else if incoming.ThoughtsTokenCount > 0 {
|
|
merged.ThoughtsTokenCount = incoming.ThoughtsTokenCount
|
|
}
|
|
if incoming.TotalTokenCount > 0 {
|
|
merged.TotalTokenCount = incoming.TotalTokenCount
|
|
}
|
|
if incoming.CachedContentTokenCount > 0 {
|
|
merged.CachedContentTokenCount = incoming.CachedContentTokenCount
|
|
}
|
|
merged.PromptTokensDetails = mergeGeminiTokenDetails(merged.PromptTokensDetails, incoming.PromptTokensDetails)
|
|
merged.ToolUsePromptTokensDetails = mergeGeminiTokenDetails(merged.ToolUsePromptTokensDetails, incoming.ToolUsePromptTokensDetails)
|
|
merged.CandidatesTokensDetails = mergeGeminiTokenDetails(merged.CandidatesTokensDetails, incoming.CandidatesTokensDetails)
|
|
if incoming.BillingUsage != nil {
|
|
merged.BillingUsage = MergeBillingUsageNonZero(merged.BillingUsage, incoming.BillingUsage)
|
|
}
|
|
if total := merged.PromptTokenCount + merged.ToolUsePromptTokenCount + merged.CandidatesTokenCount + merged.ThoughtsTokenCount; total > merged.TotalTokenCount {
|
|
merged.TotalTokenCount = total
|
|
}
|
|
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[normalizeGeminiModality(detail.Modality)] = index
|
|
}
|
|
for _, detail := range incoming {
|
|
if detail.TokenCount <= 0 {
|
|
continue
|
|
}
|
|
key := normalizeGeminiModality(detail.Modality)
|
|
if index, ok := indexes[key]; ok {
|
|
merged[index].TokenCount += detail.TokenCount
|
|
continue
|
|
}
|
|
indexes[key] = len(merged)
|
|
merged = append(merged, detail)
|
|
}
|
|
return merged
|
|
}
|
|
|
|
func mergeInputTokenDetails(current *InputTokenDetails, incoming InputTokenDetails) {
|
|
if incoming.CachedTokens > 0 {
|
|
current.CachedTokens = incoming.CachedTokens
|
|
}
|
|
if incoming.CachedCreationTokens > 0 {
|
|
current.CachedCreationTokens = incoming.CachedCreationTokens
|
|
}
|
|
if incoming.CacheWriteTokens > 0 {
|
|
current.CacheWriteTokens = incoming.CacheWriteTokens
|
|
}
|
|
if incoming.TextTokens > 0 {
|
|
current.TextTokens = incoming.TextTokens
|
|
}
|
|
if incoming.AudioTokens > 0 {
|
|
current.AudioTokens = incoming.AudioTokens
|
|
}
|
|
if incoming.ImageTokens > 0 {
|
|
current.ImageTokens = incoming.ImageTokens
|
|
}
|
|
}
|