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
|
||||
|
||||
@@ -86,6 +86,10 @@ type ChannelOtherSettings struct {
|
||||
UpstreamModelUpdateLastRemovedModels []string `json:"upstream_model_update_last_removed_models,omitempty"` // 上次检测到的可删除模型
|
||||
UpstreamModelUpdateIgnoredModels []string `json:"upstream_model_update_ignored_models,omitempty"` // 手动忽略的模型
|
||||
AdvancedCustom *AdvancedCustomConfig `json:"advanced_custom,omitempty"`
|
||||
// ToolLossPolicy is a channel-level opt-in for request-phase conversion
|
||||
// rejection. Empty follows the default allow policy. Accepted values:
|
||||
// "", "allow", "safe", "strict".
|
||||
ToolLossPolicy string `json:"tool_loss_policy,omitempty"`
|
||||
}
|
||||
|
||||
func (s *ChannelOtherSettings) IsOpenRouterEnterprise() bool {
|
||||
@@ -95,6 +99,20 @@ func (s *ChannelOtherSettings) IsOpenRouterEnterprise() bool {
|
||||
return *s.OpenRouterEnterprise
|
||||
}
|
||||
|
||||
// ValidateToolLossPolicy validates the channel-level request-phase tool-loss
|
||||
// policy. Empty keeps the default allow policy.
|
||||
func (s *ChannelOtherSettings) ValidateToolLossPolicy() error {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
switch strings.TrimSpace(s.ToolLossPolicy) {
|
||||
case "", string(types.ConversionLossPolicyAllow), string(types.ConversionLossPolicySafe), string(types.ConversionLossPolicyStrict):
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("invalid tool_loss_policy: %s", s.ToolLossPolicy)
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
advancedCustomConverterNone = "none"
|
||||
advancedCustomConverterClaudeMessagesToOpenAIChat = "anthropic_messages_to_openai_chat_completions"
|
||||
|
||||
@@ -642,3 +642,15 @@ func TestChannelSettingsValidateHTTPTransport(t *testing.T) {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "http2_connection_shards")
|
||||
}
|
||||
|
||||
func TestChannelOtherSettingsValidateToolLossPolicy(t *testing.T) {
|
||||
require.NoError(t, (*ChannelOtherSettings)(nil).ValidateToolLossPolicy())
|
||||
require.NoError(t, (&ChannelOtherSettings{}).ValidateToolLossPolicy())
|
||||
require.NoError(t, (&ChannelOtherSettings{ToolLossPolicy: "allow"}).ValidateToolLossPolicy())
|
||||
require.NoError(t, (&ChannelOtherSettings{ToolLossPolicy: "safe"}).ValidateToolLossPolicy())
|
||||
require.NoError(t, (&ChannelOtherSettings{ToolLossPolicy: "strict"}).ValidateToolLossPolicy())
|
||||
|
||||
err := (&ChannelOtherSettings{ToolLossPolicy: "drop"}).ValidateToolLossPolicy()
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "tool_loss_policy")
|
||||
}
|
||||
|
||||
+29
-7
@@ -24,10 +24,24 @@ type ClaudeMediaMessage struct {
|
||||
PartialJson *string `json:"partial_json,omitempty"`
|
||||
Role string `json:"role,omitempty"`
|
||||
Thinking *string `json:"thinking,omitempty"`
|
||||
Data string `json:"data,omitempty"`
|
||||
Signature string `json:"signature,omitempty"`
|
||||
Delta string `json:"delta,omitempty"`
|
||||
CacheControl json.RawMessage `json:"cache_control,omitempty"`
|
||||
// tool_calls
|
||||
|
||||
// Text blocks and citations_delta events.
|
||||
Citations json.RawMessage `json:"citations,omitempty"`
|
||||
Citation json.RawMessage `json:"citation,omitempty"`
|
||||
|
||||
// Server-tool and tool-result blocks.
|
||||
Caller json.RawMessage `json:"caller,omitempty"`
|
||||
ServerName string `json:"server_name,omitempty"`
|
||||
IsError *bool `json:"is_error,omitempty"`
|
||||
// ErrorCode is a relaykit compatibility extension. Claude places provider
|
||||
// error codes inside nested tool-result error content.
|
||||
ErrorCode string `json:"error_code,omitempty"`
|
||||
|
||||
// Tool-use and tool-result blocks.
|
||||
Id string `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Input any `json:"input,omitempty"`
|
||||
@@ -173,6 +187,7 @@ type Tool struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
InputSchema map[string]interface{} `json:"input_schema"`
|
||||
Strict *bool `json:"strict,omitempty"`
|
||||
}
|
||||
|
||||
type InputSchema struct {
|
||||
@@ -182,10 +197,14 @@ type InputSchema struct {
|
||||
}
|
||||
|
||||
type ClaudeWebSearchTool struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
MaxUses int `json:"max_uses,omitempty"`
|
||||
UserLocation *ClaudeWebSearchUserLocation `json:"user_location,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
MaxUses int `json:"max_uses,omitempty"`
|
||||
AllowedDomains []string `json:"allowed_domains,omitempty"`
|
||||
BlockedDomains []string `json:"blocked_domains,omitempty"`
|
||||
AllowedCallers []string `json:"allowed_callers,omitempty"`
|
||||
ResponseInclusion string `json:"response_inclusion,omitempty"`
|
||||
UserLocation *ClaudeWebSearchUserLocation `json:"user_location,omitempty"`
|
||||
}
|
||||
|
||||
type ClaudeWebSearchUserLocation struct {
|
||||
@@ -413,7 +432,7 @@ func (c *ClaudeRequest) GetTools() []any {
|
||||
|
||||
func (c *ClaudeRequest) GetEfforts() string {
|
||||
var OutputConfig OutputConfigForEffort
|
||||
if err := json.Unmarshal(c.OutputConfig, &OutputConfig); err == nil {
|
||||
if err := kitutil.Unmarshal(c.OutputConfig, &OutputConfig); err == nil {
|
||||
effort := OutputConfig.Effort
|
||||
return effort
|
||||
}
|
||||
@@ -596,5 +615,8 @@ func (u *ClaudeUsage) GetCacheCreationTotalTokens() int {
|
||||
}
|
||||
|
||||
type ClaudeServerToolUse struct {
|
||||
WebSearchRequests int `json:"web_search_requests"`
|
||||
WebSearchRequests int `json:"web_search_requests,omitempty"`
|
||||
WebFetchRequests int `json:"web_fetch_requests,omitempty"`
|
||||
CodeExecutionRequests int `json:"code_execution_requests,omitempty"`
|
||||
ToolSearchRequests int `json:"tool_search_requests,omitempty"`
|
||||
}
|
||||
|
||||
+39
-13
@@ -48,8 +48,9 @@ type ToolConfig struct {
|
||||
}
|
||||
|
||||
type FunctionCallingConfig struct {
|
||||
Mode FunctionCallingConfigMode `json:"mode,omitempty"`
|
||||
AllowedFunctionNames []string `json:"allowedFunctionNames,omitempty"`
|
||||
Mode FunctionCallingConfigMode `json:"mode,omitempty"`
|
||||
AllowedFunctionNames []string `json:"allowedFunctionNames,omitempty"`
|
||||
StreamFunctionCallArguments *bool `json:"streamFunctionCallArguments,omitempty"`
|
||||
}
|
||||
type FunctionCallingConfigMode string
|
||||
|
||||
@@ -161,8 +162,8 @@ func (r *GeminiChatRequest) SetTools(tools []GeminiChatTool) {
|
||||
}
|
||||
|
||||
type GeminiThinkingConfig struct {
|
||||
IncludeThoughts bool `json:"includeThoughts,omitempty"`
|
||||
ThinkingBudget *int `json:"thinkingBudget,omitempty"`
|
||||
IncludeThoughts *bool `json:"includeThoughts,omitempty"`
|
||||
ThinkingBudget *int `json:"thinkingBudget,omitempty"`
|
||||
// TODO Conflict with thinkingbudget.
|
||||
ThinkingLevel string `json:"thinkingLevel,omitempty"`
|
||||
}
|
||||
@@ -184,7 +185,7 @@ func (c *GeminiThinkingConfig) UnmarshalJSON(data []byte) error {
|
||||
*c = GeminiThinkingConfig(aux.Alias)
|
||||
|
||||
if aux.IncludeThoughtsSnake != nil {
|
||||
c.IncludeThoughts = *aux.IncludeThoughtsSnake
|
||||
c.IncludeThoughts = aux.IncludeThoughtsSnake
|
||||
}
|
||||
|
||||
if aux.ThinkingBudgetSnake != nil {
|
||||
@@ -239,8 +240,21 @@ func (g *GeminiInlineData) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
|
||||
type FunctionCall struct {
|
||||
FunctionName string `json:"name"`
|
||||
Arguments any `json:"args"`
|
||||
// ID is optional in the Gemini protocol and identifies the matching function response.
|
||||
ID string `json:"id,omitempty"`
|
||||
FunctionName string `json:"name"`
|
||||
Arguments any `json:"args"`
|
||||
PartialArgs []GeminiPartialArg `json:"partialArgs,omitempty"`
|
||||
WillContinue *bool `json:"willContinue,omitempty"`
|
||||
}
|
||||
|
||||
type GeminiPartialArg struct {
|
||||
JSONPath string `json:"jsonPath"`
|
||||
NumberValue *float64 `json:"numberValue,omitempty"`
|
||||
StringValue *string `json:"stringValue,omitempty"`
|
||||
BoolValue *bool `json:"boolValue,omitempty"`
|
||||
NullValue json.RawMessage `json:"nullValue,omitempty"`
|
||||
WillContinue *bool `json:"willContinue,omitempty"`
|
||||
}
|
||||
|
||||
type GeminiFunctionResponse struct {
|
||||
@@ -320,11 +334,16 @@ type GeminiChatSafetySettings struct {
|
||||
}
|
||||
|
||||
type GeminiChatTool struct {
|
||||
GoogleSearch any `json:"googleSearch,omitempty"`
|
||||
GoogleSearchRetrieval any `json:"googleSearchRetrieval,omitempty"`
|
||||
CodeExecution any `json:"codeExecution,omitempty"`
|
||||
FunctionDeclarations any `json:"functionDeclarations,omitempty"`
|
||||
URLContext any `json:"urlContext,omitempty"`
|
||||
GoogleSearch any `json:"googleSearch,omitempty"`
|
||||
GoogleSearchRetrieval any `json:"googleSearchRetrieval,omitempty"`
|
||||
GoogleMaps json.RawMessage `json:"googleMaps,omitempty"`
|
||||
EnterpriseWebSearch json.RawMessage `json:"enterpriseWebSearch,omitempty"`
|
||||
CodeExecution any `json:"codeExecution,omitempty"`
|
||||
FunctionDeclarations any `json:"functionDeclarations,omitempty"`
|
||||
URLContext any `json:"urlContext,omitempty"`
|
||||
FileSearch json.RawMessage `json:"fileSearch,omitempty"`
|
||||
ComputerUse json.RawMessage `json:"computerUse,omitempty"`
|
||||
Retrieval json.RawMessage `json:"retrieval,omitempty"`
|
||||
}
|
||||
|
||||
type GeminiChatGenerationConfig struct {
|
||||
@@ -447,7 +466,14 @@ type GeminiChatCandidate struct {
|
||||
}
|
||||
|
||||
type GeminiGroundingMetadata struct {
|
||||
WebSearchQueries []string `json:"webSearchQueries,omitempty"`
|
||||
WebSearchQueries []string `json:"webSearchQueries,omitempty"`
|
||||
RetrievalQueries []string `json:"retrievalQueries,omitempty"`
|
||||
GroundingChunks json.RawMessage `json:"groundingChunks,omitempty"`
|
||||
GroundingSupports json.RawMessage `json:"groundingSupports,omitempty"`
|
||||
SearchEntryPoint json.RawMessage `json:"searchEntryPoint,omitempty"`
|
||||
RetrievalMetadata json.RawMessage `json:"retrievalMetadata,omitempty"`
|
||||
SourceFlaggingUris json.RawMessage `json:"sourceFlaggingUris,omitempty"`
|
||||
GoogleMapsWidgetContextToken string `json:"googleMapsWidgetContextToken,omitempty"`
|
||||
}
|
||||
|
||||
type GeminiChatSafetyRating struct {
|
||||
|
||||
@@ -81,7 +81,7 @@ type GeneralOpenAIRequest struct {
|
||||
ExtraBody json.RawMessage `json:"extra_body,omitempty"`
|
||||
//xai
|
||||
SearchParameters json.RawMessage `json:"search_parameters,omitempty"`
|
||||
// claude
|
||||
// OpenAI Chat web search.
|
||||
WebSearchOptions *WebSearchOptions `json:"web_search_options,omitempty"`
|
||||
// OpenRouter Params
|
||||
Usage json.RawMessage `json:"usage,omitempty"`
|
||||
@@ -108,6 +108,9 @@ type GeneralOpenAIRequest struct {
|
||||
ReasoningSplit json.RawMessage `json:"reasoning_split,omitempty"`
|
||||
// vLLM
|
||||
ThinkingTokenBudget json.RawMessage `json:"thinking_token_budget,omitempty"`
|
||||
|
||||
// Internal conversion state; never serialized to an upstream protocol.
|
||||
ReasoningConversion *ReasoningConversionState `json:"-"`
|
||||
}
|
||||
|
||||
func (r GeneralOpenAIRequest) MarshalJSON() ([]byte, error) {
|
||||
@@ -266,6 +269,7 @@ type FunctionRequest struct {
|
||||
Name string `json:"name"`
|
||||
Parameters any `json:"parameters,omitempty"`
|
||||
Arguments string `json:"arguments,omitempty"`
|
||||
Strict *bool `json:"strict,omitempty"`
|
||||
}
|
||||
|
||||
type StreamOptions struct {
|
||||
@@ -311,7 +315,10 @@ type Message struct {
|
||||
Reasoning *string `json:"reasoning,omitempty"`
|
||||
ToolCalls json.RawMessage `json:"tool_calls,omitempty"`
|
||||
ToolCallId string `json:"tool_call_id,omitempty"`
|
||||
parsedContent []MediaContent
|
||||
// Annotations is an official Chat response field. Keeping it on the shared
|
||||
// message type also preserves annotations when clients replay assistant output.
|
||||
Annotations json.RawMessage `json:"annotations,omitempty"`
|
||||
parsedContent []MediaContent
|
||||
//parsedStringContent *string
|
||||
}
|
||||
|
||||
@@ -485,14 +492,14 @@ func (m *Message) ParseToolCalls() []ToolCallRequest {
|
||||
return nil
|
||||
}
|
||||
var toolCalls []ToolCallRequest
|
||||
if err := json.Unmarshal(m.ToolCalls, &toolCalls); err == nil {
|
||||
if err := kitutil.Unmarshal(m.ToolCalls, &toolCalls); err == nil {
|
||||
return toolCalls
|
||||
}
|
||||
return toolCalls
|
||||
}
|
||||
|
||||
func (m *Message) SetToolCalls(toolCalls any) {
|
||||
toolCallsJson, _ := json.Marshal(toolCalls)
|
||||
toolCallsJson, _ := kitutil.Marshal(toolCalls)
|
||||
m.ToolCalls = toolCallsJson
|
||||
}
|
||||
|
||||
@@ -562,6 +569,11 @@ func (m *Message) ParseContent() []MediaContent {
|
||||
return contentList
|
||||
}
|
||||
|
||||
if content, ok := m.Content.([]MediaContent); ok {
|
||||
m.parsedContent = content
|
||||
return content
|
||||
}
|
||||
|
||||
// 尝试解析为数组
|
||||
//var arrayContent []map[string]interface{}
|
||||
|
||||
@@ -682,7 +694,7 @@ func (m *Message) ParseContent() []MediaContent {
|
||||
}
|
||||
|
||||
var stringContent string
|
||||
if err := json.Unmarshal(m.Content, &stringContent); err == nil {
|
||||
if err := kitutil.Unmarshal(m.Content, &stringContent); err == nil {
|
||||
m.parsedStringContent = &stringContent
|
||||
return stringContent
|
||||
}
|
||||
@@ -707,14 +719,14 @@ func (m *Message) SetNullContent() {
|
||||
}
|
||||
|
||||
func (m *Message) SetStringContent(content string) {
|
||||
jsonContent, _ := json.Marshal(content)
|
||||
jsonContent, _ := kitutil.Marshal(content)
|
||||
m.Content = jsonContent
|
||||
m.parsedStringContent = &content
|
||||
m.parsedContent = nil
|
||||
}
|
||||
|
||||
func (m *Message) SetMediaContent(content []MediaContent) {
|
||||
jsonContent, _ := json.Marshal(content)
|
||||
jsonContent, _ := kitutil.Marshal(content)
|
||||
m.Content = jsonContent
|
||||
m.parsedContent = nil
|
||||
m.parsedStringContent = nil
|
||||
@@ -725,7 +737,7 @@ func (m *Message) IsStringContent() bool {
|
||||
return true
|
||||
}
|
||||
var stringContent string
|
||||
if err := json.Unmarshal(m.Content, &stringContent); err == nil {
|
||||
if err := kitutil.Unmarshal(m.Content, &stringContent); err == nil {
|
||||
m.parsedStringContent = &stringContent
|
||||
return true
|
||||
}
|
||||
@@ -741,7 +753,7 @@ func (m *Message) ParseContent() []MediaContent {
|
||||
|
||||
// 先尝试解析为字符串
|
||||
var stringContent string
|
||||
if err := json.Unmarshal(m.Content, &stringContent); err == nil {
|
||||
if err := kitutil.Unmarshal(m.Content, &stringContent); err == nil {
|
||||
contentList = []MediaContent{{
|
||||
Type: ContentTypeText,
|
||||
Text: stringContent,
|
||||
@@ -752,7 +764,7 @@ func (m *Message) ParseContent() []MediaContent {
|
||||
|
||||
// 尝试解析为数组
|
||||
var arrayContent []map[string]interface{}
|
||||
if err := json.Unmarshal(m.Content, &arrayContent); err == nil {
|
||||
if err := kitutil.Unmarshal(m.Content, &arrayContent); err == nil {
|
||||
for _, contentItem := range arrayContent {
|
||||
contentType, ok := contentItem["type"].(string)
|
||||
if !ok {
|
||||
@@ -907,6 +919,9 @@ type OpenAIResponsesRequest struct {
|
||||
ThinkingBudget json.RawMessage `json:"thinking_budget,omitempty"`
|
||||
// perplexity
|
||||
Preset json.RawMessage `json:"preset,omitempty"`
|
||||
|
||||
// Internal conversion state; never serialized to an upstream protocol.
|
||||
ReasoningConversion *ReasoningConversionState `json:"-"`
|
||||
}
|
||||
|
||||
func (r OpenAIResponsesRequest) MarshalJSON() ([]byte, error) {
|
||||
|
||||
+156
-15
@@ -3,6 +3,7 @@ package dto
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
@@ -91,6 +92,10 @@ type ChatCompletionsStreamResponseChoiceDelta struct {
|
||||
Reasoning *string `json:"reasoning,omitempty"`
|
||||
Role string `json:"role,omitempty"`
|
||||
ToolCalls []ToolCallResponse `json:"tool_calls,omitempty"`
|
||||
// Annotations is an OpenAI-compatible streaming extension supported by
|
||||
// providers such as OpenRouter. Relaykit uses it to preserve streaming URL
|
||||
// citations, including Claude round-trip metadata.
|
||||
Annotations json.RawMessage `json:"annotations,omitempty"`
|
||||
}
|
||||
|
||||
func (c *ChatCompletionsStreamResponseChoiceDelta) SetContentString(s string) {
|
||||
@@ -325,17 +330,143 @@ type IncompleteDetails struct {
|
||||
}
|
||||
|
||||
type ResponsesOutput struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Role string `json:"role"`
|
||||
Content []ResponsesOutputContent `json:"content"`
|
||||
Quality string `json:"quality"`
|
||||
Size string `json:"size"`
|
||||
Result string `json:"result,omitempty"`
|
||||
CallId string `json:"call_id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Arguments json.RawMessage `json:"arguments,omitempty"`
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Role string `json:"role"`
|
||||
Content []ResponsesOutputContent `json:"content"`
|
||||
Summary []ResponsesReasoningSummaryPart `json:"summary,omitempty"`
|
||||
Quality string `json:"quality"`
|
||||
Size string `json:"size"`
|
||||
Result string `json:"result,omitempty"`
|
||||
CallId string `json:"call_id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Arguments json.RawMessage `json:"arguments,omitempty"`
|
||||
Action json.RawMessage `json:"action,omitempty"`
|
||||
Queries json.RawMessage `json:"queries,omitempty"`
|
||||
Results json.RawMessage `json:"results,omitempty"`
|
||||
Sources json.RawMessage `json:"sources,omitempty"`
|
||||
Code json.RawMessage `json:"code,omitempty"`
|
||||
Outputs json.RawMessage `json:"outputs,omitempty"`
|
||||
ContainerID string `json:"container_id,omitempty"`
|
||||
PendingSafetyChecks json.RawMessage `json:"pending_safety_checks,omitempty"`
|
||||
Caller json.RawMessage `json:"caller,omitempty"`
|
||||
ServerLabel string `json:"server_label,omitempty"`
|
||||
Output json.RawMessage `json:"output,omitempty"`
|
||||
ItemError json.RawMessage `json:"error,omitempty"`
|
||||
ApprovalRequestID string `json:"approval_request_id,omitempty"`
|
||||
MCPTools json.RawMessage `json:"tools,omitempty"`
|
||||
}
|
||||
|
||||
// MarshalJSON keeps hosted-tool variants within their protocol-specific
|
||||
// schemas. ResponsesOutput also represents messages, images, and function
|
||||
// calls, whose fields must not leak into web_search_call or mcp_call items.
|
||||
func (r ResponsesOutput) MarshalJSON() ([]byte, error) {
|
||||
switch r.Type {
|
||||
case "web_search_call":
|
||||
return kitutil.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Action json.RawMessage `json:"action,omitempty"`
|
||||
}{Type: r.Type, ID: r.ID, Status: r.Status, Action: r.Action})
|
||||
case "mcp_call":
|
||||
return kitutil.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ServerLabel string `json:"server_label"`
|
||||
Arguments json.RawMessage `json:"arguments"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Output json.RawMessage `json:"output,omitempty"`
|
||||
Error json.RawMessage `json:"error,omitempty"`
|
||||
ApprovalRequestID string `json:"approval_request_id,omitempty"`
|
||||
}{
|
||||
Type: r.Type,
|
||||
ID: r.ID,
|
||||
Name: r.Name,
|
||||
ServerLabel: r.ServerLabel,
|
||||
Arguments: r.Arguments,
|
||||
Status: r.Status,
|
||||
Output: r.Output,
|
||||
Error: r.ItemError,
|
||||
ApprovalRequestID: r.ApprovalRequestID,
|
||||
})
|
||||
default:
|
||||
type responsesOutputAlias ResponsesOutput
|
||||
return kitutil.Marshal(responsesOutputAlias(r))
|
||||
}
|
||||
}
|
||||
|
||||
// NormalizeResponsesWebSearchAction validates and canonicalizes the current
|
||||
// Responses web_search_call action union. Claude emits {"query": ...}; the
|
||||
// Responses representation additionally requires a discriminator.
|
||||
func NormalizeResponsesWebSearchAction(raw json.RawMessage) (json.RawMessage, error) {
|
||||
var action struct {
|
||||
Type string `json:"type"`
|
||||
Query string `json:"query"`
|
||||
Queries []string `json:"queries"`
|
||||
Sources json.RawMessage `json:"sources"`
|
||||
URL string `json:"url"`
|
||||
Pattern string `json:"pattern"`
|
||||
}
|
||||
if err := kitutil.Unmarshal(raw, &action); err != nil {
|
||||
return nil, fmt.Errorf("decode Responses web-search action: %w", err)
|
||||
}
|
||||
action.Type = strings.TrimSpace(action.Type)
|
||||
action.Query = strings.TrimSpace(action.Query)
|
||||
action.URL = strings.TrimSpace(action.URL)
|
||||
action.Pattern = strings.TrimSpace(action.Pattern)
|
||||
for index := range action.Queries {
|
||||
action.Queries[index] = strings.TrimSpace(action.Queries[index])
|
||||
if action.Queries[index] == "" {
|
||||
return nil, fmt.Errorf("Responses web-search action queries[%d] must not be empty", index)
|
||||
}
|
||||
}
|
||||
if action.Type == "" && (action.Query != "" || len(action.Queries) > 0) {
|
||||
action.Type = "search"
|
||||
}
|
||||
|
||||
var canonical any
|
||||
switch action.Type {
|
||||
case "search":
|
||||
if action.Query == "" && len(action.Queries) == 0 {
|
||||
return nil, fmt.Errorf("Responses web-search action %q requires query or queries", action.Type)
|
||||
}
|
||||
if len(action.Sources) > 0 && kitutil.GetJsonType(action.Sources) != "array" && kitutil.GetJsonType(action.Sources) != "null" {
|
||||
return nil, fmt.Errorf("Responses web-search action sources must be an array")
|
||||
}
|
||||
canonical = struct {
|
||||
Type string `json:"type"`
|
||||
Query string `json:"query,omitempty"`
|
||||
Queries []string `json:"queries,omitempty"`
|
||||
Sources json.RawMessage `json:"sources,omitempty"`
|
||||
}{Type: action.Type, Query: action.Query, Queries: action.Queries, Sources: action.Sources}
|
||||
case "open_page":
|
||||
if action.URL == "" {
|
||||
return nil, fmt.Errorf("Responses web-search action %q requires url", action.Type)
|
||||
}
|
||||
canonical = struct {
|
||||
Type string `json:"type"`
|
||||
URL string `json:"url"`
|
||||
}{Type: action.Type, URL: action.URL}
|
||||
case "find", "find_in_page":
|
||||
if action.URL == "" || action.Pattern == "" {
|
||||
return nil, fmt.Errorf("Responses web-search action %q requires url and pattern", action.Type)
|
||||
}
|
||||
canonical = struct {
|
||||
Type string `json:"type"`
|
||||
URL string `json:"url"`
|
||||
Pattern string `json:"pattern"`
|
||||
}{Type: "find_in_page", URL: action.URL, Pattern: action.Pattern}
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported Responses web-search action type %q", action.Type)
|
||||
}
|
||||
encoded, err := kitutil.Marshal(canonical)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode Responses web-search action: %w", err)
|
||||
}
|
||||
return encoded, nil
|
||||
}
|
||||
|
||||
// ArgumentsString returns function call arguments in the string form expected by Chat Completions.
|
||||
@@ -384,10 +515,20 @@ const (
|
||||
|
||||
// ResponsesStreamResponse 用于处理 /v1/responses 流式响应
|
||||
type ResponsesStreamResponse struct {
|
||||
Type string `json:"type"`
|
||||
Response *OpenAIResponsesResponse `json:"response,omitempty"`
|
||||
Delta string `json:"delta,omitempty"`
|
||||
Item *ResponsesOutput `json:"item,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Response *OpenAIResponsesResponse `json:"response,omitempty"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Param string `json:"param,omitempty"`
|
||||
Delta string `json:"delta,omitempty"`
|
||||
Arguments *string `json:"arguments,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Text *string `json:"text,omitempty"`
|
||||
Item *ResponsesOutput `json:"item,omitempty"`
|
||||
SequenceNumber *int `json:"sequence_number,omitempty"`
|
||||
Annotation json.RawMessage `json:"annotation,omitempty"`
|
||||
AnnotationIndex *int `json:"annotation_index,omitempty"`
|
||||
Obfuscation string `json:"obfuscation,omitempty"`
|
||||
// - response.function_call_arguments.delta
|
||||
// - response.function_call_arguments.done
|
||||
OutputIndex *int `json:"output_index,omitempty"`
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package dto
|
||||
|
||||
// ReasoningConversionState carries provider-native reasoning controls between
|
||||
// in-process conversion steps. It is not part of any provider wire protocol;
|
||||
// request fields that reference it must use json:"-".
|
||||
//
|
||||
// Converters that rebuild an OpenAI request must copy this state so exact
|
||||
// budgets and explicit include-thoughts choices survive multi-step routes.
|
||||
type ReasoningConversionState struct {
|
||||
Mode string
|
||||
Effort string
|
||||
BudgetTokens *int
|
||||
IncludeThoughts *bool
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
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) {
|
||||
return CloneBillingUsage(incoming)
|
||||
}
|
||||
|
||||
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 {
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
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 mergeGeminiTokenDetails(current []GeminiPromptTokensDetails, incoming []GeminiPromptTokensDetails) []GeminiPromptTokensDetails {
|
||||
merged := append([]GeminiPromptTokensDetails{}, current...)
|
||||
indexes := make(map[string]int, len(merged))
|
||||
for index, detail := range merged {
|
||||
indexes[strings.ToUpper(strings.TrimSpace(detail.Modality))] = index
|
||||
}
|
||||
for _, detail := range incoming {
|
||||
if detail.TokenCount <= 0 {
|
||||
continue
|
||||
}
|
||||
key := strings.ToUpper(strings.TrimSpace(detail.Modality))
|
||||
if index, ok := indexes[key]; ok {
|
||||
merged[index] = detail
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMergeClaudeUsageCacheCreationReplacesWholeObject(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
merged := mergeClaudeUsageNonZero(
|
||||
&ClaudeUsage{
|
||||
CacheCreation: &ClaudeCacheCreationUsage{Ephemeral1hInputTokens: 1000},
|
||||
},
|
||||
&ClaudeUsage{
|
||||
CacheCreation: &ClaudeCacheCreationUsage{
|
||||
Ephemeral5mInputTokens: 1000,
|
||||
Ephemeral1hInputTokens: 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
require.NotNil(t, merged.CacheCreation)
|
||||
assert.Equal(t, 1000, merged.CacheCreation.Ephemeral5mInputTokens)
|
||||
assert.Equal(t, 0, merged.CacheCreation.Ephemeral1hInputTokens)
|
||||
}
|
||||
|
||||
func TestMergeGeminiUsageMetadataCandidatesAndThoughtsReplacedAsPair(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
merged := MergeGeminiUsageMetadataNonZero(
|
||||
&GeminiUsageMetadata{
|
||||
PromptTokenCount: 10,
|
||||
ThoughtsTokenCount: 100,
|
||||
},
|
||||
&GeminiUsageMetadata{
|
||||
PromptTokenCount: 10,
|
||||
CandidatesTokenCount: 150,
|
||||
ThoughtsTokenCount: 0,
|
||||
TotalTokenCount: 160,
|
||||
},
|
||||
)
|
||||
require.NotNil(t, merged)
|
||||
assert.Equal(t, 150, merged.CandidatesTokenCount)
|
||||
assert.Equal(t, 0, merged.ThoughtsTokenCount)
|
||||
|
||||
billing := NewGeminiChatBillingUsage(merged)
|
||||
usage, ok := billing.CanonicalUsage()
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, 150, usage.CompletionTokens)
|
||||
}
|
||||
|
||||
func TestMergeUsageNonZeroKeepsPositiveValuesAndTakesMaxTotal(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
merged := MergeUsageNonZero(
|
||||
&Usage{PromptTokens: 10, CompletionTokens: 5, TotalTokens: 15},
|
||||
&Usage{PromptTokens: 0, CompletionTokens: 0, TotalTokens: 20},
|
||||
)
|
||||
|
||||
require.NotNil(t, merged)
|
||||
assert.Equal(t, 10, merged.PromptTokens)
|
||||
assert.Equal(t, 5, merged.CompletionTokens)
|
||||
assert.Equal(t, 20, merged.TotalTokens)
|
||||
}
|
||||
Reference in New Issue
Block a user