mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-11 06:30:21 +00:00
* 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
211 lines
6.6 KiB
Go
211 lines
6.6 KiB
Go
package relayconvert
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"github.com/QuantumNous/new-api/relaykit/dto"
|
|
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
|
|
sharedclaude "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/claude"
|
|
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
|
|
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestClaudeDefaultMaxTokensPresence(t *testing.T) {
|
|
converters := []struct {
|
|
name string
|
|
convert func(t *testing.T, meta convmeta.Meta, clientMaxTokens *uint) (*dto.ClaudeRequest, error)
|
|
}{
|
|
{
|
|
name: "chat completions",
|
|
convert: func(t *testing.T, meta convmeta.Meta, clientMaxTokens *uint) (*dto.ClaudeRequest, error) {
|
|
t.Helper()
|
|
return OpenAIChatRequestToClaudeMessages(context.Background(), meta, dto.GeneralOpenAIRequest{
|
|
Model: "claude-test",
|
|
MaxTokens: clientMaxTokens,
|
|
Messages: []dto.Message{
|
|
{Role: "user", Content: "hello"},
|
|
},
|
|
})
|
|
},
|
|
},
|
|
{
|
|
name: "responses",
|
|
convert: func(t *testing.T, meta convmeta.Meta, clientMaxTokens *uint) (*dto.ClaudeRequest, error) {
|
|
t.Helper()
|
|
return OpenAIResponsesRequestToClaudeMessages(context.Background(), meta, &dto.OpenAIResponsesRequest{
|
|
Model: "claude-test",
|
|
Input: []byte(`"hello"`),
|
|
MaxOutputTokens: clientMaxTokens,
|
|
})
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, converter := range converters {
|
|
t.Run(converter.name, func(t *testing.T) {
|
|
t.Run("callback absent fails conversion", func(t *testing.T) {
|
|
got, err := converter.convert(t, &convmeta.Values{}, nil)
|
|
require.ErrorIs(t, err, sharedclaude.ErrMissingMaxTokens)
|
|
assert.Nil(t, got)
|
|
})
|
|
|
|
t.Run("callback absent, client value wins", func(t *testing.T) {
|
|
clientMaxTokens := uint(99)
|
|
got, err := converter.convert(t, &convmeta.Values{}, &clientMaxTokens)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, got.MaxTokens)
|
|
assert.Equal(t, clientMaxTokens, *got.MaxTokens)
|
|
})
|
|
|
|
t.Run("configured zero", func(t *testing.T) {
|
|
got, err := converter.convert(t, claudeDefaultsMeta(func(string) int { return 0 }), nil)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, got.MaxTokens)
|
|
assert.Zero(t, *got.MaxTokens)
|
|
})
|
|
|
|
t.Run("configured positive", func(t *testing.T) {
|
|
got, err := converter.convert(t, claudeDefaultsMeta(func(string) int { return 512 }), nil)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, got.MaxTokens)
|
|
assert.Equal(t, uint(512), *got.MaxTokens)
|
|
})
|
|
|
|
t.Run("client nonzero wins", func(t *testing.T) {
|
|
clientMaxTokens := uint(99)
|
|
got, err := converter.convert(t, claudeDefaultsMeta(func(string) int { return 512 }), &clientMaxTokens)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, got.MaxTokens)
|
|
assert.Equal(t, clientMaxTokens, *got.MaxTokens)
|
|
})
|
|
|
|
t.Run("client zero same as absent, hook fills", func(t *testing.T) {
|
|
clientMaxTokens := uint(0)
|
|
got, err := converter.convert(t, claudeDefaultsMeta(func(string) int { return 512 }), &clientMaxTokens)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, got.MaxTokens)
|
|
assert.Equal(t, uint(512), *got.MaxTokens)
|
|
})
|
|
|
|
t.Run("client zero same as absent, no hook fails", func(t *testing.T) {
|
|
clientMaxTokens := uint(0)
|
|
got, err := converter.convert(t, &convmeta.Values{}, &clientMaxTokens)
|
|
require.ErrorIs(t, err, sharedclaude.ErrMissingMaxTokens)
|
|
assert.Nil(t, got)
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
// The thinking adapter's max_tokens floor is an injection path of its own: a
|
|
// "-thinking" request without max_tokens must keep converting even when no
|
|
// DefaultMaxTokens hook is configured.
|
|
func TestClaudeThinkingAdapterSatisfiesMaxTokensWithoutCallback(t *testing.T) {
|
|
_, intent, found, err := reasoning.ParseClaudeModelSuffix("claude-test-thinking", true)
|
|
require.NoError(t, err)
|
|
require.True(t, found)
|
|
meta := &convmeta.Values{
|
|
ReasoningConversion: reasoning.StateFromIntent(intent),
|
|
Options: &convmeta.Options{
|
|
Claude: convmeta.ClaudeOptions{
|
|
ThinkingAdapterEnabled: true,
|
|
ThinkingAdapterBudgetTokensPercentage: 0.8,
|
|
},
|
|
},
|
|
}
|
|
got, err := OpenAIChatRequestToClaudeMessages(context.Background(), meta, dto.GeneralOpenAIRequest{
|
|
Model: "claude-test-thinking",
|
|
Messages: []dto.Message{
|
|
{Role: "user", Content: "hello"},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, got.MaxTokens)
|
|
assert.Equal(t, uint(1280), *got.MaxTokens)
|
|
}
|
|
|
|
func TestOpenAIChatRequestToClaudeMessagesOmitsEmptyTools(t *testing.T) {
|
|
maxTokens := uint(16)
|
|
tests := []struct {
|
|
name string
|
|
request dto.GeneralOpenAIRequest
|
|
wantTools bool
|
|
}{
|
|
{
|
|
name: "omitted tools",
|
|
request: dto.GeneralOpenAIRequest{
|
|
Model: "claude-test",
|
|
MaxTokens: &maxTokens,
|
|
Messages: []dto.Message{{Role: "user", Content: "hi"}},
|
|
},
|
|
},
|
|
{
|
|
name: "explicit empty tools",
|
|
request: dto.GeneralOpenAIRequest{
|
|
Model: "claude-test",
|
|
MaxTokens: &maxTokens,
|
|
Messages: []dto.Message{{Role: "user", Content: "hi"}},
|
|
Tools: []dto.ToolCallRequest{},
|
|
},
|
|
},
|
|
{
|
|
name: "function tool",
|
|
request: dto.GeneralOpenAIRequest{
|
|
Model: "claude-test",
|
|
MaxTokens: &maxTokens,
|
|
Messages: []dto.Message{{Role: "user", Content: "hi"}},
|
|
Tools: []dto.ToolCallRequest{{
|
|
Type: "function",
|
|
Function: dto.FunctionRequest{
|
|
Name: "get_weather",
|
|
Description: "Get weather by city",
|
|
Parameters: map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{"city": map[string]any{"type": "string"}},
|
|
"required": []any{"city"},
|
|
},
|
|
},
|
|
}},
|
|
},
|
|
wantTools: true,
|
|
},
|
|
{
|
|
name: "web search only",
|
|
request: dto.GeneralOpenAIRequest{
|
|
Model: "claude-test",
|
|
MaxTokens: &maxTokens,
|
|
Messages: []dto.Message{{Role: "user", Content: "hi"}},
|
|
WebSearchOptions: &dto.WebSearchOptions{SearchContextSize: "low"},
|
|
},
|
|
wantTools: true,
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
got, err := OpenAIChatRequestToClaudeMessages(context.Background(), &convmeta.Values{}, test.request)
|
|
require.NoError(t, err)
|
|
|
|
body, err := kitutil.Marshal(got)
|
|
require.NoError(t, err)
|
|
|
|
if test.wantTools {
|
|
assert.NotNil(t, got.Tools)
|
|
assert.Contains(t, string(body), `"tools":`)
|
|
return
|
|
}
|
|
assert.Nil(t, got.Tools)
|
|
assert.NotContains(t, string(body), `"tools":`)
|
|
})
|
|
}
|
|
}
|
|
|
|
func claudeDefaultsMeta(defaultMaxTokens func(string) int) convmeta.Meta {
|
|
return &convmeta.Values{Options: &convmeta.Options{
|
|
Claude: convmeta.ClaudeOptions{DefaultMaxTokens: defaultMaxTokens},
|
|
}}
|
|
}
|