Files
new-api/service/relayconvert/response_registry_test.go
T
CaIon 48068ce923 feat: bill OpenAI cache_write_tokens at cache-creation price with zero clamp
Parse OpenAI's native cache_write_tokens (chat prompt_tokens_details /
responses input_tokens_details), bill it at the cache-creation ratio, and
clamp the uncached prompt remainder at zero since cached + cache-write can
exceed prompt_tokens. Propagate the field through chat/responses/claude
format conversions and tiered expression billing (cc variable).
2026-07-11 21:18:49 +08:00

674 lines
27 KiB
Go

package relayconvert
import (
"testing"
"github.com/QuantumNous/new-api/dto"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestLookupBuiltinResponseConverters(t *testing.T) {
tests := []struct {
lookupID string
id string
from types.RelayFormat
to types.RelayFormat
quality ResponseConverterQuality
stepConverters []string
}{
{lookupID: ResponseConverterOAIChatToOAIResponses, id: ConverterOpenAIChatToOpenAIResponses, from: types.RelayFormatOpenAI, to: types.RelayFormatOpenAIResponses, quality: ResponseConverterQualityGood},
{lookupID: ResponseConverterOAIResponsesToOAIChat, id: ConverterOpenAIResponsesToOpenAIChat, from: types.RelayFormatOpenAIResponses, to: types.RelayFormatOpenAI, quality: ResponseConverterQualityGood},
{lookupID: ResponseConverterOAIChatToClaudeMessages, id: ConverterOpenAIChatToClaudeMessages, from: types.RelayFormatOpenAI, to: types.RelayFormatClaude, quality: ResponseConverterQualityFair},
{lookupID: ResponseConverterOAIChatToGeminiChat, id: ConverterOpenAIChatToGeminiContent, from: types.RelayFormatOpenAI, to: types.RelayFormatGemini, quality: ResponseConverterQualityFair},
{lookupID: ResponseConverterClaudeMessagesToOAIChat, id: ConverterClaudeMessagesToOpenAIChat, from: types.RelayFormatClaude, to: types.RelayFormatOpenAI, quality: ResponseConverterQualityFair},
{lookupID: ResponseConverterGeminiChatToOAIChat, id: ConverterGeminiContentToOpenAIChat, from: types.RelayFormatGemini, to: types.RelayFormatOpenAI, quality: ResponseConverterQualityFair},
{
lookupID: responseConverterClaudeToGemini,
id: requestConverterClaudeToGemini,
from: types.RelayFormatClaude,
to: types.RelayFormatGemini,
quality: ResponseConverterQualityDiscouraged,
stepConverters: []string{
ConverterClaudeMessagesToOpenAIChat,
ConverterOpenAIChatToGeminiContent,
},
},
{
lookupID: responseConverterClaudeToResponses,
id: requestConverterClaudeToResponses,
from: types.RelayFormatClaude,
to: types.RelayFormatOpenAIResponses,
quality: ResponseConverterQualityFair,
stepConverters: []string{
ConverterClaudeMessagesToOpenAIChat,
ConverterOpenAIChatToOpenAIResponses,
},
},
{
lookupID: responseConverterGeminiToClaude,
id: requestConverterGeminiToClaude,
from: types.RelayFormatGemini,
to: types.RelayFormatClaude,
quality: ResponseConverterQualityDiscouraged,
stepConverters: []string{
ConverterGeminiContentToOpenAIChat,
ConverterOpenAIChatToClaudeMessages,
},
},
{
lookupID: responseConverterGeminiToResponses,
id: requestConverterGeminiToResponses,
from: types.RelayFormatGemini,
to: types.RelayFormatOpenAIResponses,
quality: ResponseConverterQualityFair,
stepConverters: []string{
ConverterGeminiContentToOpenAIChat,
ConverterOpenAIChatToOpenAIResponses,
},
},
{
lookupID: responseConverterResponsesToClaude,
id: requestConverterResponsesToClaude,
from: types.RelayFormatOpenAIResponses,
to: types.RelayFormatClaude,
quality: ResponseConverterQualityFair,
stepConverters: []string{
ConverterOpenAIResponsesToOpenAIChat,
ConverterOpenAIChatToClaudeMessages,
},
},
{
lookupID: responseConverterResponsesToGemini,
id: ConverterOpenAIResponsesToGemini,
from: types.RelayFormatOpenAIResponses,
to: types.RelayFormatGemini,
quality: ResponseConverterQualityFair,
stepConverters: []string{
ConverterOpenAIResponsesToOpenAIChat,
ConverterOpenAIChatToGeminiContent,
},
},
}
for _, tt := range tests {
t.Run(tt.lookupID, func(t *testing.T) {
spec, ok := LookupResponseConverter(tt.lookupID)
require.True(t, ok)
assert.Equal(t, tt.id, spec.ID)
assert.Equal(t, tt.from, spec.From)
assert.Equal(t, tt.to, spec.To)
assert.Equal(t, tt.quality, spec.Quality)
assert.Equal(t, tt.stepConverters, spec.StepConverters)
if len(tt.stepConverters) == 0 {
assert.NotNil(t, spec.Convert)
} else {
assert.Nil(t, spec.Convert)
}
})
}
_, ok := LookupResponseConverter("missing")
assert.False(t, ok)
}
func TestConvertResponseRejectsNilAndUnsupportedRoute(t *testing.T) {
_, err := ConvertResponse(nil, nil, types.RelayFormatOpenAI, (*dto.OpenAITextResponse)(nil))
require.Error(t, err)
_, err = ConvertResponse(nil, nil, types.RelayFormatEmbedding, &dto.OpenAITextResponse{})
require.Error(t, err)
}
func TestConvertResponseDirectConverters(t *testing.T) {
chat := textRegistryChatResponse()
info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "gemini-test"}}
toResponses, err := ConvertResponse(nil, info, types.RelayFormatOpenAIResponses, chat)
require.NoError(t, err)
assert.Equal(t, ConverterOpenAIChatToOpenAIResponses, toResponses.Converter)
assert.Equal(t, ResponseConverterQualityGood, toResponses.Quality)
assert.Equal(t, types.RelayFormatOpenAI, toResponses.From)
assert.Equal(t, types.RelayFormat(types.RelayFormatOpenAIResponses), toResponses.To)
assert.Equal(t, []ResponseStep{{Converter: ConverterOpenAIChatToOpenAIResponses, From: types.RelayFormatOpenAI, To: types.RelayFormatOpenAIResponses}}, toResponses.Steps)
require.IsType(t, &dto.OpenAIResponsesResponse{}, toResponses.Value)
assert.Equal(t, 9, toResponses.Usage.TotalTokens)
require.NotNil(t, toResponses.Usage.BillingUsage)
require.NotNil(t, toResponses.Usage.BillingUsage.OpenAIUsage)
assert.Equal(t, dto.BillingUsageSourceOAIChat, toResponses.Usage.BillingUsage.Source)
assert.Equal(t, 4, toResponses.Usage.BillingUsage.OpenAIUsage.PromptTokens)
responses := &dto.OpenAIResponsesResponse{
ID: "resp_1",
CreatedAt: 123,
Model: "gpt-test",
Status: []byte(`"completed"`),
Output: []dto.ResponsesOutput{
{
Type: "message",
Role: "assistant",
Content: []dto.ResponsesOutputContent{
{Type: "output_text", Text: "hello"},
},
},
},
Usage: &dto.Usage{InputTokens: 4, OutputTokens: 6, TotalTokens: 10},
}
toChat, err := ConvertResponse(nil, info, types.RelayFormatOpenAI, responses)
require.NoError(t, err)
assert.Equal(t, ConverterOpenAIResponsesToOpenAIChat, toChat.Converter)
assert.Equal(t, ResponseConverterQualityGood, toChat.Quality)
require.IsType(t, &dto.OpenAITextResponse{}, toChat.Value)
assert.Equal(t, 10, toChat.Usage.TotalTokens)
require.NotNil(t, toChat.Usage.BillingUsage)
require.NotNil(t, toChat.Usage.BillingUsage.OpenAIUsage)
assert.Equal(t, dto.BillingUsageSourceOAIResponses, toChat.Usage.BillingUsage.Source)
assert.Equal(t, 4, toChat.Usage.BillingUsage.OpenAIUsage.InputTokens)
toClaude, err := ConvertResponse(nil, info, types.RelayFormatClaude, chat)
require.NoError(t, err)
assert.Equal(t, ConverterOpenAIChatToClaudeMessages, toClaude.Converter)
assert.Equal(t, ResponseConverterQualityFair, toClaude.Quality)
require.IsType(t, &dto.ClaudeResponse{}, toClaude.Value)
assert.Equal(t, 9, toClaude.Usage.TotalTokens)
require.NotNil(t, toClaude.Usage.BillingUsage)
require.NotNil(t, toClaude.Usage.BillingUsage.OpenAIUsage)
claudeValue := toClaude.Value.(*dto.ClaudeResponse)
require.NotNil(t, claudeValue.Usage)
require.NotNil(t, claudeValue.Usage.BillingUsage)
require.NotNil(t, claudeValue.Usage.BillingUsage.OpenAIUsage)
toGemini, err := ConvertResponse(nil, info, types.RelayFormatGemini, chat)
require.NoError(t, err)
assert.Equal(t, ConverterOpenAIChatToGeminiContent, toGemini.Converter)
assert.Equal(t, ResponseConverterQualityFair, toGemini.Quality)
require.IsType(t, &dto.GeminiChatResponse{}, toGemini.Value)
assert.Equal(t, 9, toGemini.Usage.TotalTokens)
require.NotNil(t, toGemini.Usage.BillingUsage)
require.NotNil(t, toGemini.Usage.BillingUsage.OpenAIUsage)
geminiValue := toGemini.Value.(*dto.GeminiChatResponse)
require.NotNil(t, geminiValue.UsageMetadata.BillingUsage)
require.NotNil(t, geminiValue.UsageMetadata.BillingUsage.OpenAIUsage)
}
func TestConvertResponseMultiHopConverters(t *testing.T) {
responses := textRegistryResponsesResponse()
toClaude, err := ConvertResponse(nil, &relaycommon.RelayInfo{}, types.RelayFormatClaude, responses)
require.NoError(t, err)
assert.Equal(t, requestConverterResponsesToClaude, toClaude.Converter)
assert.Equal(t, ResponseConverterQualityFair, toClaude.Quality)
assert.Equal(t, []ResponseStep{
{Converter: ConverterOpenAIResponsesToOpenAIChat, From: types.RelayFormatOpenAIResponses, To: types.RelayFormatOpenAI},
{Converter: ConverterOpenAIChatToClaudeMessages, From: types.RelayFormatOpenAI, To: types.RelayFormatClaude},
}, toClaude.Steps)
require.IsType(t, &dto.ClaudeResponse{}, toClaude.Value)
claudeValue := toClaude.Value.(*dto.ClaudeResponse)
require.Len(t, claudeValue.Content, 2)
assert.Equal(t, "text", claudeValue.Content[0].Type)
assert.Equal(t, "tool_use", claudeValue.Content[1].Type)
assert.Equal(t, "lookup", claudeValue.Content[1].Name)
assert.Equal(t, map[string]interface{}{"q": "x"}, claudeValue.Content[1].Input)
assert.Equal(t, 11, toClaude.Usage.TotalTokens)
toGemini, err := ConvertResponse(nil, &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "gemini-test"}}, types.RelayFormatGemini, responses)
require.NoError(t, err)
assert.Equal(t, ConverterOpenAIResponsesToGemini, toGemini.Converter)
assert.Equal(t, ResponseConverterQualityFair, toGemini.Quality)
assert.Equal(t, []ResponseStep{
{Converter: ConverterOpenAIResponsesToOpenAIChat, From: types.RelayFormatOpenAIResponses, To: types.RelayFormatOpenAI},
{Converter: ConverterOpenAIChatToGeminiContent, From: types.RelayFormatOpenAI, To: types.RelayFormatGemini},
}, toGemini.Steps)
require.IsType(t, &dto.GeminiChatResponse{}, toGemini.Value)
geminiValue := toGemini.Value.(*dto.GeminiChatResponse)
require.Len(t, geminiValue.Candidates, 1)
require.Len(t, geminiValue.Candidates[0].Content.Parts, 2)
assert.Equal(t, "hello", geminiValue.Candidates[0].Content.Parts[0].Text)
require.NotNil(t, geminiValue.Candidates[0].Content.Parts[1].FunctionCall)
assert.Equal(t, "lookup", geminiValue.Candidates[0].Content.Parts[1].FunctionCall.FunctionName)
assert.Equal(t, map[string]interface{}{"q": "x"}, geminiValue.Candidates[0].Content.Parts[1].FunctionCall.Arguments)
assert.Equal(t, 11, toGemini.Usage.TotalTokens)
}
func TestConvertResponseByIDExecutesMultiHopAndChecksSource(t *testing.T) {
responses := textRegistryResponsesResponse()
result, err := ConvertResponseByID(nil, nil, responseConverterResponsesToGemini, responses)
require.NoError(t, err)
assert.Equal(t, ConverterOpenAIResponsesToGemini, result.Converter)
assert.Equal(t, []ResponseStep{
{Converter: ConverterOpenAIResponsesToOpenAIChat, From: types.RelayFormatOpenAIResponses, To: types.RelayFormatOpenAI},
{Converter: ConverterOpenAIChatToGeminiContent, From: types.RelayFormatOpenAI, To: types.RelayFormatGemini},
}, result.Steps)
_, err = ConvertResponseByID(nil, nil, responseConverterResponsesToGemini, textRegistryChatResponse())
require.Error(t, err)
}
func TestConvertResponseProviderToOAIChatUsage(t *testing.T) {
claude := &dto.ClaudeResponse{
Id: "msg_1",
Type: "message",
Role: "assistant",
Model: "claude-test",
StopReason: "end_turn",
Content: []dto.ClaudeMediaMessage{
{Type: "tool_use", Id: "toolu_1", Name: "lookup", Input: map[string]interface{}{"q": "x"}},
},
Usage: &dto.ClaudeUsage{
InputTokens: 10,
CacheReadInputTokens: 3,
CacheCreationInputTokens: 4,
OutputTokens: 5,
CacheCreation: &dto.ClaudeCacheCreationUsage{
Ephemeral5mInputTokens: 1,
Ephemeral1hInputTokens: 3,
},
},
}
toChat, err := ConvertResponse(nil, nil, types.RelayFormatOpenAI, claude)
require.NoError(t, err)
assert.Equal(t, ConverterClaudeMessagesToOpenAIChat, toChat.Converter)
require.IsType(t, &dto.OpenAITextResponse{}, toChat.Value)
assert.Equal(t, 17, toChat.Usage.PromptTokens)
assert.Equal(t, 5, toChat.Usage.CompletionTokens)
assert.Equal(t, 22, toChat.Usage.TotalTokens)
assert.Equal(t, 3, toChat.Usage.PromptTokensDetails.CachedTokens)
assert.Equal(t, 4, toChat.Usage.PromptTokensDetails.CachedCreationTokens)
assert.Equal(t, 4, toChat.Usage.PromptTokensDetails.CacheWriteTokens)
require.NotNil(t, toChat.Usage.BillingUsage)
require.NotNil(t, toChat.Usage.BillingUsage.ClaudeUsage)
assert.Equal(t, dto.BillingUsageSourceClaudeMessages, toChat.Usage.BillingUsage.Source)
assert.Equal(t, dto.BillingUsageSemanticAnthropic, toChat.Usage.BillingUsage.Semantic)
assert.Equal(t, 10, toChat.Usage.BillingUsage.ClaudeUsage.InputTokens)
assert.Equal(t, 3, toChat.Usage.BillingUsage.ClaudeUsage.CacheReadInputTokens)
assert.Equal(t, 4, toChat.Usage.BillingUsage.ClaudeUsage.CacheCreationInputTokens)
assert.Equal(t, 5, toChat.Usage.BillingUsage.ClaudeUsage.OutputTokens)
chatValue := toChat.Value.(*dto.OpenAITextResponse)
require.Len(t, chatValue.Choices, 1)
require.Len(t, chatValue.Choices[0].Message.ParseToolCalls(), 1)
assert.JSONEq(t, `{"q":"x"}`, chatValue.Choices[0].Message.ParseToolCalls()[0].Function.Arguments)
gemini := &dto.GeminiChatResponse{
Candidates: []dto.GeminiChatCandidate{
{
Content: dto.GeminiChatContent{
Parts: []dto.GeminiPart{
{Text: "hello"},
{FunctionCall: &dto.FunctionCall{FunctionName: "lookup", Arguments: map[string]interface{}{"q": "x"}}},
},
},
},
},
UsageMetadata: dto.GeminiUsageMetadata{
PromptTokenCount: 7,
ToolUsePromptTokenCount: 2,
CandidatesTokenCount: 5,
ThoughtsTokenCount: 3,
TotalTokenCount: 17,
CachedContentTokenCount: 4,
PromptTokensDetails: []dto.GeminiPromptTokensDetails{
{Modality: "TEXT", TokenCount: 5},
{Modality: "IMAGE", TokenCount: 1},
},
ToolUsePromptTokensDetails: []dto.GeminiPromptTokensDetails{
{Modality: "AUDIO", TokenCount: 3},
},
CandidatesTokensDetails: []dto.GeminiPromptTokensDetails{
{Modality: "TEXT", TokenCount: 4},
{Modality: "IMAGE", TokenCount: 1},
},
},
}
toChat, err = ConvertResponse(nil, &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "gemini-test"}}, types.RelayFormatOpenAI, gemini)
require.NoError(t, err)
assert.Equal(t, ConverterGeminiContentToOpenAIChat, toChat.Converter)
require.IsType(t, &dto.OpenAITextResponse{}, toChat.Value)
assert.Equal(t, 9, toChat.Usage.PromptTokens)
assert.Equal(t, 8, toChat.Usage.CompletionTokens)
assert.Equal(t, 17, toChat.Usage.TotalTokens)
assert.Equal(t, 3, toChat.Usage.CompletionTokenDetails.ReasoningTokens)
assert.Equal(t, 4, toChat.Usage.PromptTokensDetails.CachedTokens)
assert.Equal(t, 5, toChat.Usage.PromptTokensDetails.TextTokens)
assert.Equal(t, 3, toChat.Usage.PromptTokensDetails.AudioTokens)
assert.Equal(t, 1, toChat.Usage.PromptTokensDetails.ImageTokens)
assert.Equal(t, 4, toChat.Usage.CompletionTokenDetails.TextTokens)
assert.Equal(t, 1, toChat.Usage.CompletionTokenDetails.ImageTokens)
require.NotNil(t, toChat.Usage.BillingUsage)
require.NotNil(t, toChat.Usage.BillingUsage.GeminiUsageMetadata)
assert.Equal(t, dto.BillingUsageSourceGeminiChat, toChat.Usage.BillingUsage.Source)
assert.Equal(t, dto.BillingUsageSemanticGemini, toChat.Usage.BillingUsage.Semantic)
assert.Equal(t, 7, toChat.Usage.BillingUsage.GeminiUsageMetadata.PromptTokenCount)
assert.Equal(t, 2, toChat.Usage.BillingUsage.GeminiUsageMetadata.ToolUsePromptTokenCount)
assert.Equal(t, 17, toChat.Usage.BillingUsage.GeminiUsageMetadata.TotalTokenCount)
}
func TestConvertResponsePreservesBillingUsageAcrossChatResponsesBridge(t *testing.T) {
chat := textRegistryChatResponse()
chat.Usage.BillingUsage = dto.NewClaudeMessagesBillingUsage(&dto.ClaudeUsage{
InputTokens: 10,
CacheReadInputTokens: 3,
CacheCreationInputTokens: 4,
OutputTokens: 5,
})
toResponses, err := ConvertResponse(nil, nil, types.RelayFormatOpenAIResponses, chat)
require.NoError(t, err)
require.NotNil(t, toResponses.Usage.BillingUsage)
require.NotNil(t, toResponses.Usage.BillingUsage.ClaudeUsage)
assert.Equal(t, 10, toResponses.Usage.BillingUsage.ClaudeUsage.InputTokens)
responsesValue := toResponses.Value.(*dto.OpenAIResponsesResponse)
toChat, err := ConvertResponse(nil, nil, types.RelayFormatOpenAI, responsesValue)
require.NoError(t, err)
require.NotNil(t, toChat.Usage.BillingUsage)
require.NotNil(t, toChat.Usage.BillingUsage.ClaudeUsage)
assert.Equal(t, 4, toChat.Usage.BillingUsage.ClaudeUsage.CacheCreationInputTokens)
}
func TestConvertResponseUsesBillingUsageWhenRestoringNativeTargets(t *testing.T) {
chat := textRegistryChatResponse()
chat.Usage.BillingUsage = dto.NewClaudeMessagesBillingUsage(&dto.ClaudeUsage{
InputTokens: 10,
CacheReadInputTokens: 3,
CacheCreationInputTokens: 4,
OutputTokens: 5,
})
toClaude, err := ConvertResponse(nil, nil, types.RelayFormatClaude, chat)
require.NoError(t, err)
claudeValue := toClaude.Value.(*dto.ClaudeResponse)
require.NotNil(t, claudeValue.Usage)
assert.Equal(t, 10, claudeValue.Usage.InputTokens)
assert.Equal(t, 3, claudeValue.Usage.CacheReadInputTokens)
assert.Equal(t, 4, claudeValue.Usage.CacheCreationInputTokens)
assert.Equal(t, 5, claudeValue.Usage.OutputTokens)
chat.Usage.BillingUsage = dto.NewGeminiChatBillingUsage(&dto.GeminiUsageMetadata{
PromptTokenCount: 7,
ToolUsePromptTokenCount: 2,
CandidatesTokenCount: 5,
ThoughtsTokenCount: 3,
TotalTokenCount: 17,
})
toGemini, err := ConvertResponse(nil, nil, types.RelayFormatGemini, chat)
require.NoError(t, err)
geminiValue := toGemini.Value.(*dto.GeminiChatResponse)
assert.Equal(t, 7, geminiValue.UsageMetadata.PromptTokenCount)
assert.Equal(t, 2, geminiValue.UsageMetadata.ToolUsePromptTokenCount)
assert.Equal(t, 5, geminiValue.UsageMetadata.CandidatesTokenCount)
assert.Equal(t, 3, geminiValue.UsageMetadata.ThoughtsTokenCount)
assert.Equal(t, 17, geminiValue.UsageMetadata.TotalTokenCount)
}
func TestConvertStreamResponseDirectConverters(t *testing.T) {
info := &relaycommon.RelayInfo{
ClaudeConvertInfo: &relaycommon.ClaudeConvertInfo{
LastMessagesType: relaycommon.LastMessageTypeNone,
},
}
info.SendResponseCount = 1
finishReason := "stop"
result, err := ConvertStreamResponse(nil, info, types.RelayFormatClaude, &dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.ChatCompletionsStreamResponseChoice{
{
FinishReason: &finishReason,
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{
Content: respPtr("hello"),
},
},
},
Usage: &dto.Usage{PromptTokens: 2, CompletionTokens: 3, TotalTokens: 5},
})
require.NoError(t, err)
assert.True(t, result.Stream)
assert.Equal(t, ConverterOpenAIChatToClaudeMessages, result.Converter)
require.IsType(t, []*dto.ClaudeResponse{}, result.Value)
assert.Equal(t, 5, result.Usage.TotalTokens)
result, err = ConvertStreamResponse(nil, &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "gemini-test"}}, types.RelayFormatOpenAI, &dto.GeminiChatResponse{
Candidates: []dto.GeminiChatCandidate{{Content: dto.GeminiChatContent{Parts: []dto.GeminiPart{{Text: "hello"}}}}},
UsageMetadata: dto.GeminiUsageMetadata{
PromptTokenCount: 1,
CandidatesTokenCount: 2,
TotalTokenCount: 3,
},
})
require.NoError(t, err)
assert.True(t, result.Stream)
assert.Equal(t, ConverterGeminiContentToOpenAIChat, result.Converter)
require.IsType(t, &dto.ChatCompletionsStreamResponse{}, result.Value)
assert.Equal(t, 3, result.Usage.TotalTokens)
}
func TestConvertStreamResponseStatefulDirectConverters(t *testing.T) {
chatState, err := NewResponseStreamState(types.RelayFormatOpenAI, types.RelayFormatOpenAIResponses, ResponseStreamOptions{
ID: "resp_1",
Model: "gpt-test",
})
require.NoError(t, err)
chatResults, err := ConvertStreamResponseChunk(nil, nil, chatState, &dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.ChatCompletionsStreamResponseChoice{
{Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: respPtr("hello")}},
},
Usage: &dto.Usage{PromptTokens: 2, CompletionTokens: 3, TotalTokens: 5},
})
require.NoError(t, err)
require.NotEmpty(t, chatResults)
assert.Equal(t, ConverterOpenAIChatToOpenAIResponses, chatResults[0].Converter)
assert.Equal(t, []ResponseStep{{Converter: ConverterOpenAIChatToOpenAIResponses, From: types.RelayFormatOpenAI, To: types.RelayFormatOpenAIResponses}}, chatResults[0].Steps)
assert.Equal(t, 5, chatState.Usage().TotalTokens)
finalResults, err := FinalizeStreamResponse(nil, nil, chatState)
require.NoError(t, err)
require.NotEmpty(t, finalResults)
lastEvent, ok := finalResults[len(finalResults)-1].Value.(ChatToResponsesStreamEvent)
require.True(t, ok)
assert.Equal(t, "response.completed", lastEvent.Type)
responsesState, err := NewResponseStreamState(types.RelayFormatOpenAIResponses, types.RelayFormatOpenAI, ResponseStreamOptions{
ID: "chatcmpl_1",
Model: "gpt-test",
})
require.NoError(t, err)
responsesResults, err := ConvertStreamResponseChunk(nil, nil, responsesState, &dto.ResponsesStreamResponse{
Type: "response.output_text.delta",
Delta: "hello",
})
require.NoError(t, err)
require.NotEmpty(t, responsesResults)
assert.Equal(t, ConverterOpenAIResponsesToOpenAIChat, responsesResults[0].Converter)
assert.Equal(t, []ResponseStep{{Converter: ConverterOpenAIResponsesToOpenAIChat, From: types.RelayFormatOpenAIResponses, To: types.RelayFormatOpenAI}}, responsesResults[0].Steps)
require.IsType(t, dto.ChatCompletionsStreamResponse{}, responsesResults[len(responsesResults)-1].Value)
}
func TestConvertStreamResponseStatefulMultiHopResponsesToClaude(t *testing.T) {
info := &relaycommon.RelayInfo{
ClaudeConvertInfo: &relaycommon.ClaudeConvertInfo{
LastMessagesType: relaycommon.LastMessageTypeNone,
},
}
state, err := NewResponseStreamState(types.RelayFormatOpenAIResponses, types.RelayFormatClaude, ResponseStreamOptions{
ID: "chatcmpl_1",
Model: "gpt-test",
})
require.NoError(t, err)
results, err := ConvertStreamResponseChunk(nil, info, state, &dto.ResponsesStreamResponse{
Type: "response.output_text.delta",
Delta: "hello",
})
require.NoError(t, err)
require.NotEmpty(t, results)
assert.Equal(t, requestConverterResponsesToClaude, results[0].Converter)
assert.Equal(t, []ResponseStep{
{Converter: ConverterOpenAIResponsesToOpenAIChat, From: types.RelayFormatOpenAIResponses, To: types.RelayFormatOpenAI},
{Converter: ConverterOpenAIChatToClaudeMessages, From: types.RelayFormatOpenAI, To: types.RelayFormatClaude},
}, results[0].Steps)
var sawTextDelta bool
for _, result := range results {
claudeResponse, ok := result.Value.(*dto.ClaudeResponse)
if !ok || claudeResponse == nil {
continue
}
if claudeResponse.Type == "content_block_delta" && claudeResponse.Delta != nil && claudeResponse.Delta.Text != nil && *claudeResponse.Delta.Text == "hello" {
sawTextDelta = true
}
}
assert.True(t, sawTextDelta)
state.SetUsage(&dto.Usage{PromptTokens: 2, CompletionTokens: 3, TotalTokens: 5})
_, err = FinalizeStreamResponse(nil, info, state)
require.NoError(t, err)
assert.Equal(t, 5, state.Usage().TotalTokens)
}
func TestResponseUsageMatrixChatAndResponsesDetails(t *testing.T) {
chat := textRegistryChatResponse()
chat.Usage = dto.Usage{
PromptTokens: 10,
CompletionTokens: 5,
TotalTokens: 20,
PromptTokensDetails: dto.InputTokenDetails{
CachedTokens: 3,
CachedCreationTokens: 2,
CacheWriteTokens: 6,
TextTokens: 4,
AudioTokens: 1,
ImageTokens: 5,
},
CompletionTokenDetails: dto.OutputTokenDetails{
ReasoningTokens: 2,
TextTokens: 2,
AudioTokens: 1,
ImageTokens: 2,
},
}
result, err := ConvertResponse(nil, nil, types.RelayFormatOpenAIResponses, chat)
require.NoError(t, err)
assert.Equal(t, 10, result.Usage.InputTokens)
assert.Equal(t, 5, result.Usage.OutputTokens)
assert.Equal(t, 20, result.Usage.TotalTokens)
require.NotNil(t, result.Usage.InputTokensDetails)
assert.Equal(t, 3, result.Usage.InputTokensDetails.CachedTokens)
assert.Equal(t, 2, result.Usage.InputTokensDetails.CachedCreationTokens)
assert.Equal(t, 6, result.Usage.InputTokensDetails.CacheWriteTokens)
assert.Equal(t, 4, result.Usage.InputTokensDetails.TextTokens)
assert.Equal(t, 1, result.Usage.InputTokensDetails.AudioTokens)
assert.Equal(t, 5, result.Usage.InputTokensDetails.ImageTokens)
assert.Equal(t, 2, result.Usage.CompletionTokenDetails.ReasoningTokens)
assert.Equal(t, 2, result.Usage.CompletionTokenDetails.TextTokens)
assert.Equal(t, 1, result.Usage.CompletionTokenDetails.AudioTokens)
assert.Equal(t, 2, result.Usage.CompletionTokenDetails.ImageTokens)
responses := &dto.OpenAIResponsesResponse{
ID: "resp_1",
Status: []byte(`"completed"`),
Model: "gpt-test",
Output: []dto.ResponsesOutput{},
CreatedAt: 123,
Usage: &dto.Usage{
InputTokens: 12,
OutputTokens: 8,
TotalTokens: 21,
InputTokensDetails: &dto.InputTokenDetails{
CachedTokens: 4,
CachedCreationTokens: 1,
CacheWriteTokens: 7,
TextTokens: 5,
AudioTokens: 2,
ImageTokens: 1,
},
CompletionTokenDetails: dto.OutputTokenDetails{
ReasoningTokens: 3,
TextTokens: 4,
AudioTokens: 1,
ImageTokens: 3,
},
},
}
result, err = ConvertResponse(nil, nil, types.RelayFormatOpenAI, responses)
require.NoError(t, err)
assert.Equal(t, 12, result.Usage.PromptTokens)
assert.Equal(t, 8, result.Usage.CompletionTokens)
assert.Equal(t, 21, result.Usage.TotalTokens)
assert.Equal(t, 4, result.Usage.PromptTokensDetails.CachedTokens)
assert.Equal(t, 1, result.Usage.PromptTokensDetails.CachedCreationTokens)
assert.Equal(t, 7, result.Usage.PromptTokensDetails.CacheWriteTokens)
assert.Equal(t, 5, result.Usage.PromptTokensDetails.TextTokens)
assert.Equal(t, 2, result.Usage.PromptTokensDetails.AudioTokens)
assert.Equal(t, 1, result.Usage.PromptTokensDetails.ImageTokens)
assert.Equal(t, 3, result.Usage.CompletionTokenDetails.ReasoningTokens)
assert.Equal(t, 4, result.Usage.CompletionTokenDetails.TextTokens)
assert.Equal(t, 1, result.Usage.CompletionTokenDetails.AudioTokens)
assert.Equal(t, 3, result.Usage.CompletionTokenDetails.ImageTokens)
}
func textRegistryChatResponse() *dto.OpenAITextResponse {
msg := dto.Message{
Role: "assistant",
Content: "hello",
}
msg.SetToolCalls([]dto.ToolCallRequest{
{
ID: "call_1",
Type: "function",
Function: dto.FunctionRequest{
Name: "lookup",
Arguments: `{"q":"x"}`,
},
},
})
return &dto.OpenAITextResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Created: 123,
Choices: []dto.OpenAITextResponseChoice{
{
Index: 0,
Message: msg,
FinishReason: "tool_calls",
},
},
Usage: dto.Usage{PromptTokens: 4, CompletionTokens: 5, TotalTokens: 9},
}
}
func textRegistryResponsesResponse() *dto.OpenAIResponsesResponse {
return &dto.OpenAIResponsesResponse{
ID: "resp_1",
CreatedAt: 123,
Model: "gpt-test",
Status: []byte(`"completed"`),
Output: []dto.ResponsesOutput{
{
Type: "message",
Role: "assistant",
Content: []dto.ResponsesOutputContent{
{Type: "output_text", Text: "hello"},
},
},
{
Type: "function_call",
ID: "call_1",
CallId: "call_1",
Name: "lookup",
Arguments: []byte(`{"q":"x"}`),
},
},
Usage: &dto.Usage{InputTokens: 4, OutputTokens: 7, TotalTokens: 11},
}
}
func respPtr[T any](value T) *T {
return &value
}