mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-12 23:30:35 +00:00
feat(relay): hosted-tool conversion fidelity, reasoning normalization, and billing usage integrity (#7137)
* 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
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
package dto
|
||||
|
||||
import "strings"
|
||||
|
||||
const (
|
||||
BillingUsageSourceClaudeMessages = "claude_messages"
|
||||
BillingUsageSourceGeminiChat = "gemini_chat"
|
||||
@@ -100,7 +102,15 @@ func HasOpenAIUsageTokens(usage *Usage) bool {
|
||||
usage.CompletionTokenDetails.AudioTokens != 0 {
|
||||
return true
|
||||
}
|
||||
return usage.InputTokensDetails != nil
|
||||
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 {
|
||||
@@ -111,15 +121,92 @@ 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
|
||||
}
|
||||
return newGeminiChatBillingUsage(&GeminiUsageMetadata{
|
||||
PromptTokenCount: usage.PromptTokens,
|
||||
CandidatesTokenCount: usage.CompletionTokens,
|
||||
TotalTokenCount: totalTokens,
|
||||
}, true)
|
||||
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 {
|
||||
@@ -149,6 +236,165 @@ func CloneBillingUsage(usage *BillingUsage) *BillingUsage {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user