Files
new-api/relay/channel/claude/adaptor_test.go
T
Calcium-Ion bbd97446c2 fix(relay): follow-up billing integrity and conversion completions (#7170)
Deferred follow-ups from the relaykit-tools review cycle, verified by
live end-to-end billing tests:

- billing: normalize Gemini modality keys consistently between stream
  merge and settlement (case/whitespace variants no longer drop
  independent audio/image pricing) and sum duplicate modality entries
  on both paths
- billing: sync legacy flat Claude cache-creation fields from the
  CacheCreation sub-object (including zeroing) and fall back to flat
  fields only when the snapshot never carried a sub-object, closing a
  stale 1h-cache overcharge path in cascaded deployments
- relay: move Chat-to-Claude and Chat-to-Gemini stream conversion state
  from gin.Context onto RelayInfo and reset it with SendResponseCount in
  InitChannelMeta, so channel retries start clean while per-request
  state (stream error collection, conversion diagnostics, channel
  chain, billing accumulators) survives
- relay: Claude channel now serves Gemini-format clients (request via
  registry conversion, response and stream composed through the Chat
  pivot), removing the last unimplemented conversion direction
- relaykit: recognize legacy pseudo tool names (googleSearch,
  codeExecution, urlContext) in the toolconv decode stage and drop the
  string-matching bypass in the Chat-to-Gemini converter; native Gemini
  tool output is restored and non-Gemini targets follow standard loss
  diagnostics
- relaykit: attach upstream Gemini usage (with billing_usage sidecar)
  to intermediate stream chunks so converted Claude streams report
  upstream truth from message_start, and preserve the sidecar through
  Claude stream usage merges; billing settlement unchanged
- billing: clamp negative Total-Prompt completion derivation, OR the
  Estimated flag across cross-dialect snapshot replacement, and fill
  canonical OpenAI prompt details via field-wise merge
2026-09-03 10:40:05 +08:00

188 lines
5.5 KiB
Go

package claude
import (
"net/http/httptest"
"testing"
"github.com/QuantumNous/new-api/common"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relay/helper"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/setting/model_setting"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestConvertClaudeRequestTreatsZeroMaxTokensAsUnset(t *testing.T) {
zero := uint(0)
req := &dto.ClaudeRequest{
Model: "claude-sonnet-4-5",
MaxTokens: &zero,
Messages: []dto.ClaudeMessage{
{Role: "user", Content: "hello"},
},
}
info := &relaycommon.RelayInfo{
ChannelMeta: &relaycommon.ChannelMeta{
UpstreamModelName: "claude-sonnet-4-5",
},
}
out, err := (&Adaptor{}).ConvertClaudeRequest(nil, info, req)
require.NoError(t, err)
converted, ok := out.(*dto.ClaudeRequest)
require.True(t, ok)
require.NotNil(t, converted.MaxTokens)
assert.Equal(t, uint(model_setting.GetClaudeSettings().GetDefaultMaxTokens(req.Model)), *converted.MaxTokens)
}
func TestConvertClaudeRequestZeroMaxTokensStillRaisesThinkingBudget(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
zero := uint(0)
original := &dto.ClaudeRequest{
Model: "claude-3-7-sonnet-thinking",
MaxTokens: &zero,
Messages: []dto.ClaudeMessage{
{Role: "user", Content: "hello"},
},
}
info := &relaycommon.RelayInfo{
OriginModelName: "claude-3-7-sonnet-thinking",
Request: original,
ChannelMeta: &relaycommon.ChannelMeta{
UpstreamModelName: "claude-3-7-sonnet-thinking",
},
}
outbound, err := common.DeepCopy(original)
require.NoError(t, err)
require.NoError(t, helper.ModelMappedHelper(c, info, outbound))
require.NoError(t, helper.ApplyReasoningModelSuffix(info, outbound))
out, err := (&Adaptor{}).ConvertClaudeRequest(nil, info, outbound)
require.NoError(t, err)
converted, ok := out.(*dto.ClaudeRequest)
require.True(t, ok)
assert.Equal(t, "claude-3-7-sonnet", converted.Model)
require.NotNil(t, converted.Thinking)
require.NotNil(t, converted.MaxTokens)
assert.Greater(t, *converted.MaxTokens, uint(1024))
}
func TestConvertClaudeRequestDoesNotOverwriteTrimmedUpstreamModelName(t *testing.T) {
req := &dto.ClaudeRequest{
Model: "claude-3-7-sonnet-thinking",
Messages: []dto.ClaudeMessage{
{Role: "user", Content: "hello"},
},
}
info := &relaycommon.RelayInfo{
ChannelMeta: &relaycommon.ChannelMeta{
UpstreamModelName: "claude-3-7-sonnet",
},
}
_, err := (&Adaptor{}).ConvertClaudeRequest(nil, info, req)
require.NoError(t, err)
assert.Equal(t, "claude-3-7-sonnet", info.UpstreamModelName)
}
func geminiToClaudeInfo() *relaycommon.RelayInfo {
return &relaycommon.RelayInfo{
OriginModelName: "claude-3-7-sonnet",
ChannelMeta: &relaycommon.ChannelMeta{
UpstreamModelName: "claude-3-7-sonnet",
},
}
}
func TestConvertGeminiRequestMapsSystemInstructionToolsAndMultimodal(t *testing.T) {
req := &dto.GeminiChatRequest{
Contents: []dto.GeminiChatContent{
{
Role: "user",
Parts: []dto.GeminiPart{
{Text: "What is in this image?"},
{InlineData: &dto.GeminiInlineData{MimeType: "image/png", Data: "aGVsbG8="}},
},
},
},
SystemInstructions: &dto.GeminiChatContent{
Parts: []dto.GeminiPart{{Text: "You are a helpful assistant."}},
},
}
req.SetTools([]dto.GeminiChatTool{
{
FunctionDeclarations: []dto.FunctionRequest{
{
Name: "lookup",
Description: "Lookup data",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{"q": map[string]any{"type": "string"}},
},
},
},
},
})
out, err := (&Adaptor{}).ConvertGeminiRequest(nil, geminiToClaudeInfo(), req)
require.NoError(t, err)
converted, ok := out.(*dto.ClaudeRequest)
require.True(t, ok)
system := converted.ParseSystem()
require.NotEmpty(t, system)
assert.Contains(t, system[0].GetText(), "You are a helpful assistant.")
require.NotEmpty(t, converted.Messages)
assert.Equal(t, "user", converted.Messages[0].Role)
blocks, parseErr := converted.Messages[0].ParseContent()
require.NoError(t, parseErr)
var foundImage bool
for _, block := range blocks {
if block.Type == "image" || (block.Source != nil && block.Source.Type == "base64") {
foundImage = true
break
}
}
assert.True(t, foundImage)
require.NotNil(t, converted.Tools)
tools, err := common.Marshal(converted.Tools)
require.NoError(t, err)
assert.Contains(t, string(tools), `"lookup"`)
require.NotNil(t, converted.MaxTokens)
assert.Greater(t, *converted.MaxTokens, uint(0))
}
func TestConvertGeminiRequestThinkingConfigUsesReasoningIntent(t *testing.T) {
budget := 1024
maxTokens := uint(4096)
req := &dto.GeminiChatRequest{
Contents: []dto.GeminiChatContent{
{Role: "user", Parts: []dto.GeminiPart{{Text: "think"}}},
},
GenerationConfig: dto.GeminiChatGenerationConfig{
MaxOutputTokens: &maxTokens,
ThinkingConfig: &dto.GeminiThinkingConfig{ThinkingBudget: &budget},
},
}
out, err := (&Adaptor{}).ConvertGeminiRequest(nil, geminiToClaudeInfo(), req)
require.NoError(t, err)
converted, ok := out.(*dto.ClaudeRequest)
require.True(t, ok)
require.NotNil(t, converted.Thinking)
assert.Equal(t, "enabled", converted.Thinking.Type)
require.NotNil(t, converted.Thinking.BudgetTokens)
assert.Equal(t, 1024, *converted.Thinking.BudgetTokens)
}
func TestConvertGeminiRequestNilRequest(t *testing.T) {
_, err := (&Adaptor{}).ConvertGeminiRequest(nil, geminiToClaudeInfo(), nil)
require.Error(t, err)
}