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:
Calcium-Ion
2026-09-01 21:53:35 +08:00
committed by GitHub
parent b7017c251b
commit 0ed497f066
149 changed files with 14729 additions and 3044 deletions
+252 -6
View File
@@ -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