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
+18
View File
@@ -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"
+12
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+25 -10
View File
@@ -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
View File
@@ -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"`
+14
View File
@@ -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
}
+281
View File
@@ -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(&current.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
}
}
+67
View File
@@ -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)
}