Files
new-api/relaykit/dto/billing_usage.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

458 lines
16 KiB
Go

package dto
import "strings"
const (
BillingUsageSourceClaudeMessages = "claude_messages"
BillingUsageSourceGeminiChat = "gemini_chat"
BillingUsageSourceOAIChat = "oai_chat"
BillingUsageSourceOAIResponses = "oai_responses"
BillingUsageSemanticAnthropic = "anthropic"
BillingUsageSemanticGemini = "gemini"
BillingUsageSemanticOpenAI = "openai"
)
type BillingUsage struct {
Source string `json:"source,omitempty"`
Semantic string `json:"semantic,omitempty"`
Estimated bool `json:"estimated,omitempty"`
OpenAIUsage *Usage `json:"openai_usage,omitempty"`
ClaudeUsage *ClaudeUsage `json:"claude_usage,omitempty"`
GeminiUsageMetadata *GeminiUsageMetadata `json:"gemini_usage_metadata,omitempty"`
}
func NewClaudeMessagesBillingUsage(usage *ClaudeUsage) *BillingUsage {
if !HasClaudeUsageTokens(usage) {
return nil
}
return &BillingUsage{
Source: BillingUsageSourceClaudeMessages,
Semantic: BillingUsageSemanticAnthropic,
ClaudeUsage: cloneClaudeUsage(usage),
}
}
// HasClaudeUsageTokens mirrors HasOpenAIUsageTokens/HasGeminiUsageMetadataTokens:
// an all-zero ClaudeUsage must not become a BillingUsage, otherwise it would take
// precedence during settlement and zero out a non-zero top-level usage.
func HasClaudeUsageTokens(usage *ClaudeUsage) bool {
if usage == nil {
return false
}
if usage.InputTokens != 0 ||
usage.OutputTokens != 0 ||
usage.CacheCreationInputTokens != 0 ||
usage.CacheReadInputTokens != 0 ||
usage.ClaudeCacheCreation5mTokens != 0 ||
usage.ClaudeCacheCreation1hTokens != 0 {
return true
}
if usage.CacheCreation != nil &&
(usage.CacheCreation.Ephemeral5mInputTokens != 0 || usage.CacheCreation.Ephemeral1hInputTokens != 0) {
return true
}
return false
}
func NewOpenAIChatBillingUsage(usage *Usage) *BillingUsage {
return newOpenAIBillingUsage(BillingUsageSourceOAIChat, usage)
}
func NewOpenAIResponsesBillingUsage(usage *Usage) *BillingUsage {
return newOpenAIBillingUsage(BillingUsageSourceOAIResponses, usage)
}
func newOpenAIBillingUsage(source string, usage *Usage) *BillingUsage {
if !HasOpenAIUsageTokens(usage) {
return nil
}
return &BillingUsage{
Source: source,
Semantic: BillingUsageSemanticOpenAI,
OpenAIUsage: cloneOpenAIUsage(usage),
}
}
func HasOpenAIUsageTokens(usage *Usage) bool {
if usage == nil {
return false
}
if usage.PromptTokens != 0 ||
usage.CompletionTokens != 0 ||
usage.TotalTokens != 0 ||
usage.InputTokens != 0 ||
usage.OutputTokens != 0 ||
usage.PromptCacheHitTokens != 0 ||
usage.ClaudeCacheCreation5mTokens != 0 ||
usage.ClaudeCacheCreation1hTokens != 0 {
return true
}
if usage.PromptTokensDetails.CachedTokens != 0 ||
usage.PromptTokensDetails.CachedCreationTokens != 0 ||
usage.PromptTokensDetails.CacheWriteTokens != 0 ||
usage.PromptTokensDetails.TextTokens != 0 ||
usage.PromptTokensDetails.ImageTokens != 0 ||
usage.PromptTokensDetails.AudioTokens != 0 {
return true
}
if usage.CompletionTokenDetails.ReasoningTokens != 0 ||
usage.CompletionTokenDetails.TextTokens != 0 ||
usage.CompletionTokenDetails.ImageTokens != 0 ||
usage.CompletionTokenDetails.AudioTokens != 0 {
return true
}
if usage.InputTokensDetails == nil {
return false
}
return usage.InputTokensDetails.CachedTokens != 0 ||
usage.InputTokensDetails.CachedCreationTokens != 0 ||
usage.InputTokensDetails.CacheWriteTokens != 0 ||
usage.InputTokensDetails.TextTokens != 0 ||
usage.InputTokensDetails.ImageTokens != 0 ||
usage.InputTokensDetails.AudioTokens != 0
}
func NewGeminiChatBillingUsage(metadata *GeminiUsageMetadata) *BillingUsage {
return newGeminiChatBillingUsage(metadata, false)
}
func NewEstimatedGeminiChatBillingUsage(usage *Usage) *BillingUsage {
if usage == nil {
return nil
}
reasoningTokens := usage.CompletionTokenDetails.ReasoningTokens
candidateTokens := usage.CompletionTokens - reasoningTokens
if candidateTokens < 0 {
candidateTokens = 0
}
totalTokens := usage.TotalTokens
if totalTokens == 0 {
totalTokens = usage.PromptTokens + usage.CompletionTokens
}
metadata := &GeminiUsageMetadata{
PromptTokenCount: usage.PromptTokens,
CandidatesTokenCount: candidateTokens,
TotalTokenCount: totalTokens,
ThoughtsTokenCount: reasoningTokens,
CachedContentTokenCount: usage.PromptTokensDetails.CachedTokens,
}
for _, detail := range []GeminiPromptTokensDetails{
{Modality: "TEXT", TokenCount: usage.PromptTokensDetails.TextTokens},
{Modality: "IMAGE", TokenCount: usage.PromptTokensDetails.ImageTokens},
{Modality: "AUDIO", TokenCount: usage.PromptTokensDetails.AudioTokens},
} {
if detail.TokenCount != 0 {
metadata.PromptTokensDetails = append(metadata.PromptTokensDetails, detail)
}
}
for _, detail := range []GeminiPromptTokensDetails{
{Modality: "TEXT", TokenCount: usage.CompletionTokenDetails.TextTokens},
{Modality: "IMAGE", TokenCount: usage.CompletionTokenDetails.ImageTokens},
{Modality: "AUDIO", TokenCount: usage.CompletionTokenDetails.AudioTokens},
} {
if detail.TokenCount != 0 {
metadata.CandidatesTokensDetails = append(metadata.CandidatesTokensDetails, detail)
}
}
return newGeminiChatBillingUsage(metadata, true)
}
// CloneBillingUsageWithEstimatedCompletion preserves the original upstream
// billing dialect and fills a missing completion count without rebuilding the
// payload from a converted, potentially lossy Usage value.
func CloneBillingUsageWithEstimatedCompletion(usage *BillingUsage, completionTokens int) *BillingUsage {
clone := CloneBillingUsage(usage)
if clone == nil || completionTokens <= 0 {
return clone
}
updated := false
switch {
case clone.OpenAIUsage != nil:
openAIUsage := clone.OpenAIUsage
if openAIUsage.CompletionTokens == 0 && openAIUsage.OutputTokens == 0 {
openAIUsage.CompletionTokens = completionTokens
openAIUsage.OutputTokens = completionTokens
inputTokens := openAIUsage.PromptTokens
if inputTokens == 0 {
inputTokens = openAIUsage.InputTokens
}
if totalTokens := inputTokens + completionTokens; openAIUsage.TotalTokens < totalTokens {
openAIUsage.TotalTokens = totalTokens
}
updated = true
}
case clone.ClaudeUsage != nil:
if clone.ClaudeUsage.OutputTokens == 0 {
clone.ClaudeUsage.OutputTokens = completionTokens
updated = true
}
case clone.GeminiUsageMetadata != nil:
metadata := clone.GeminiUsageMetadata
if metadata.CandidatesTokenCount == 0 {
candidateTokens := completionTokens - metadata.ThoughtsTokenCount
if candidateTokens < 0 {
candidateTokens = 0
}
metadata.CandidatesTokenCount = candidateTokens
totalTokens := metadata.PromptTokenCount + metadata.ToolUsePromptTokenCount + metadata.CandidatesTokenCount + metadata.ThoughtsTokenCount
if metadata.TotalTokenCount < totalTokens {
metadata.TotalTokenCount = totalTokens
}
updated = true
}
}
if updated {
clone.Estimated = true
}
return clone
}
func newGeminiChatBillingUsage(metadata *GeminiUsageMetadata, estimated bool) *BillingUsage {
if !HasGeminiUsageMetadataTokens(metadata) {
return nil
}
usageMetadata := cloneGeminiUsageMetadata(*metadata)
return &BillingUsage{
Source: BillingUsageSourceGeminiChat,
Semantic: BillingUsageSemanticGemini,
Estimated: estimated,
GeminiUsageMetadata: &usageMetadata,
}
}
func CloneBillingUsage(usage *BillingUsage) *BillingUsage {
if usage == nil {
return nil
}
clone := *usage
clone.OpenAIUsage = cloneOpenAIUsage(usage.OpenAIUsage)
clone.ClaudeUsage = cloneClaudeUsage(usage.ClaudeUsage)
if usage.GeminiUsageMetadata != nil {
metadata := cloneGeminiUsageMetadata(*usage.GeminiUsageMetadata)
clone.GeminiUsageMetadata = &metadata
}
return &clone
}
// CanonicalUsage decodes the original provider usage carried across relay
// hops into the shared accounting shape. The BillingUsage snapshot remains the
// source of truth and is cloned onto the returned value for further relays.
func (usage *BillingUsage) CanonicalUsage() (*Usage, bool) {
if usage == nil {
return nil, false
}
source := strings.TrimSpace(usage.Source)
semantic := strings.TrimSpace(usage.Semantic)
// A structurally recognized but all-zero payload must not become the
// settlement source of truth; rejecting it lets settlement fall back to a
// non-zero top-level usage.
if HasOpenAIUsageTokens(usage.OpenAIUsage) &&
(strings.EqualFold(source, BillingUsageSourceOAIChat) ||
strings.EqualFold(source, BillingUsageSourceOAIResponses) ||
strings.EqualFold(semantic, BillingUsageSemanticOpenAI)) {
return usage.canonicalOpenAIUsage(), true
}
if HasClaudeUsageTokens(usage.ClaudeUsage) &&
(strings.EqualFold(source, BillingUsageSourceClaudeMessages) ||
strings.EqualFold(semantic, BillingUsageSemanticAnthropic)) {
return usage.canonicalClaudeUsage(), true
}
if HasGeminiUsageMetadataTokens(usage.GeminiUsageMetadata) &&
(strings.EqualFold(source, BillingUsageSourceGeminiChat) ||
strings.EqualFold(semantic, BillingUsageSemanticGemini)) {
return usage.canonicalGeminiUsage(), true
}
return nil, false
}
func (usage *BillingUsage) canonicalOpenAIUsage() *Usage {
canonical := cloneOpenAIUsage(usage.OpenAIUsage)
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
}
if canonical.PromptTokens == 0 && canonical.InputTokens > 0 {
canonical.PromptTokens = canonical.InputTokens
}
if canonical.CompletionTokens == 0 && canonical.OutputTokens > 0 {
canonical.CompletionTokens = canonical.OutputTokens
}
if canonical.InputTokens == 0 && canonical.PromptTokens > 0 {
canonical.InputTokens = canonical.PromptTokens
}
if canonical.OutputTokens == 0 && canonical.CompletionTokens > 0 {
canonical.OutputTokens = canonical.CompletionTokens
}
if canonical.TotalTokens == 0 {
canonical.TotalTokens = canonical.PromptTokens + canonical.CompletionTokens
}
canonical.UsageSemantic = BillingUsageSemanticOpenAI
canonical.UsageSource = usage.Source
canonical.BillingUsage = CloneBillingUsage(usage)
return canonical
}
func (usage *BillingUsage) canonicalClaudeUsage() *Usage {
claudeUsage := usage.ClaudeUsage
// 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.ClaudeCacheCreation1hTokens
}
canonical := &Usage{
PromptTokens: claudeUsage.InputTokens,
CompletionTokens: claudeUsage.OutputTokens,
TotalTokens: claudeUsage.InputTokens + claudeUsage.OutputTokens,
InputTokens: claudeUsage.InputTokens + claudeUsage.CacheReadInputTokens + claudeUsage.CacheCreationInputTokens,
OutputTokens: claudeUsage.OutputTokens,
UsageSemantic: BillingUsageSemanticAnthropic,
UsageSource: BillingUsageSourceClaudeMessages,
BillingUsage: CloneBillingUsage(usage),
ClaudeCacheCreation5mTokens: cacheCreation5m,
ClaudeCacheCreation1hTokens: cacheCreation1h,
}
canonical.PromptTokensDetails.CachedTokens = claudeUsage.CacheReadInputTokens
canonical.PromptTokensDetails.CachedCreationTokens = claudeUsage.CacheCreationInputTokens
return canonical
}
func (usage *BillingUsage) canonicalGeminiUsage() *Usage {
metadata := usage.GeminiUsageMetadata
promptTokens := metadata.PromptTokenCount + metadata.ToolUsePromptTokenCount
canonical := &Usage{
PromptTokens: promptTokens,
CompletionTokens: metadata.CandidatesTokenCount + metadata.ThoughtsTokenCount,
TotalTokens: metadata.TotalTokenCount,
UsageSemantic: BillingUsageSemanticGemini,
UsageSource: BillingUsageSourceGeminiChat,
BillingUsage: CloneBillingUsage(usage),
}
canonical.CompletionTokenDetails.ReasoningTokens = metadata.ThoughtsTokenCount
canonical.PromptTokensDetails.CachedTokens = metadata.CachedContentTokenCount
for _, detail := range metadata.PromptTokensDetails {
addGeminiInputTokenDetail(&canonical.PromptTokensDetails, detail)
}
for _, detail := range metadata.ToolUsePromptTokensDetails {
addGeminiInputTokenDetail(&canonical.PromptTokensDetails, detail)
}
for _, detail := range metadata.CandidatesTokensDetails {
switch normalizeGeminiModality(detail.Modality) {
case "IMAGE":
canonical.CompletionTokenDetails.ImageTokens += detail.TokenCount
case "AUDIO":
canonical.CompletionTokenDetails.AudioTokens += detail.TokenCount
case "TEXT":
canonical.CompletionTokenDetails.TextTokens += detail.TokenCount
}
}
if canonical.TotalTokens == 0 {
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
}
return canonical
}
func addGeminiInputTokenDetail(details *InputTokenDetails, detail GeminiPromptTokensDetails) {
switch normalizeGeminiModality(detail.Modality) {
case "AUDIO":
details.AudioTokens += detail.TokenCount
case "IMAGE":
details.ImageTokens += detail.TokenCount
case "TEXT":
details.TextTokens += detail.TokenCount
}
}
func cloneOpenAIUsage(usage *Usage) *Usage {
if usage == nil {
return nil
}
clone := *usage
clone.BillingUsage = nil
if usage.InputTokensDetails != nil {
inputTokensDetails := *usage.InputTokensDetails
clone.InputTokensDetails = &inputTokensDetails
}
return &clone
}
func cloneClaudeUsage(usage *ClaudeUsage) *ClaudeUsage {
if usage == nil {
return nil
}
clone := *usage
clone.BillingUsage = nil
if usage.CacheCreation != nil {
cacheCreation := *usage.CacheCreation
clone.CacheCreation = &cacheCreation
}
if usage.ServerToolUse != nil {
serverToolUse := *usage.ServerToolUse
clone.ServerToolUse = &serverToolUse
}
return &clone
}
func cloneGeminiUsageMetadata(metadata GeminiUsageMetadata) GeminiUsageMetadata {
metadata.PromptTokensDetails = append([]GeminiPromptTokensDetails{}, metadata.PromptTokensDetails...)
metadata.ToolUsePromptTokensDetails = append([]GeminiPromptTokensDetails{}, metadata.ToolUsePromptTokensDetails...)
metadata.CandidatesTokensDetails = append([]GeminiPromptTokensDetails{}, metadata.CandidatesTokensDetails...)
metadata.BillingUsage = nil
return metadata
}
func HasGeminiUsageMetadataTokens(metadata *GeminiUsageMetadata) bool {
if metadata == nil {
return false
}
if metadata.PromptTokenCount != 0 ||
metadata.ToolUsePromptTokenCount != 0 ||
metadata.CandidatesTokenCount != 0 ||
metadata.TotalTokenCount != 0 ||
metadata.ThoughtsTokenCount != 0 ||
metadata.CachedContentTokenCount != 0 {
return true
}
for _, detail := range metadata.PromptTokensDetails {
if detail.TokenCount != 0 {
return true
}
}
for _, detail := range metadata.ToolUsePromptTokensDetails {
if detail.TokenCount != 0 {
return true
}
}
for _, detail := range metadata.CandidatesTokensDetails {
if detail.TokenCount != 0 {
return true
}
}
return false
}