mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-11 14:41:21 +00:00
* feat(relaykit): preserve hosted tools across conversions - add protocol-neutral hosted-tool DTOs, conversion metadata, and loss policies - bridge citations, grounding metadata, and hosted-tool stream lifecycles - document the public conversion behavior and channel policy controls * refactor(relaykit): normalize reasoning and thinking intent - centralize provider-neutral reasoning intent, effort, and budget mappings - parse model suffixes at the host entry boundary while preserving provider-owned tails - keep adaptive Claude thinking and explicit zero-token compatibility consistent * fix(billing): preserve authoritative usage across relay hops - carry native BillingUsage sidecars through direct and streamed protocol bridges - merge partial and terminal usage monotonically with safe fallback settlement - retain cache metadata, penultimate usage, and per-call Gemini tool surcharges * feat(relay): bridge Responses with Claude and Gemini protocols - add direct request, response, and stream converters across supported relay formats - expose Claude count_tokens and Chat-to-Responses compatibility endpoints - carry conversion diagnostics through the host while retaining the curated public goldens * fix(relay): wire relaykit conversions into host channels - connect handlers, adaptors, and channel settings to the standalone conversion layer - keep model mapping, pricing identity, retries, and provider-specific suffix behavior aligned - ignore local audit artifacts and retain focused public regression coverage
465 lines
16 KiB
Go
465 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 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.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
|
|
cacheCreation5m := claudeUsage.GetCacheCreation5mTokens()
|
|
if cacheCreation5m == 0 {
|
|
cacheCreation5m = claudeUsage.ClaudeCacheCreation5mTokens
|
|
}
|
|
cacheCreation1h := claudeUsage.GetCacheCreation1hTokens()
|
|
if cacheCreation1h == 0 {
|
|
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 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.PromptTokens > 0 && canonical.PromptTokensDetails.TextTokens == 0 && canonical.PromptTokensDetails.AudioTokens == 0 {
|
|
canonical.PromptTokensDetails.TextTokens = canonical.PromptTokens
|
|
}
|
|
return canonical
|
|
}
|
|
|
|
func addGeminiInputTokenDetail(details *InputTokenDetails, detail GeminiPromptTokensDetails) {
|
|
switch 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
|
|
}
|