Files
new-api/controller/channel_test_request_test.go

241 lines
12 KiB
Go

package controller
import (
"net/http/httptest"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/relay/channel/ali"
"github.com/QuantumNous/new-api/relay/channel/openai"
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/relaykit/types"
"github.com/QuantumNous/new-api/setting/model_setting"
"github.com/gin-gonic/gin"
"github.com/samber/lo"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func convertChatCompatibilityRequest(t *testing.T, request *dto.GeneralOpenAIRequest, channelType int, mapping map[string]string) []byte {
t.Helper()
oldMode := gin.Mode()
gin.SetMode(gin.TestMode)
t.Cleanup(func() { gin.SetMode(oldMode) })
settings := model_setting.GetGlobalSettings()
oldPassThrough, oldBlacklist := settings.PassThroughRequestEnabled, settings.ThinkingModelBlacklist
oldEffortTailModels := settings.EffortTailModelIDs
settings.PassThroughRequestEnabled = false
settings.ThinkingModelBlacklist = nil
settings.EffortTailModelIDs = []string{"gpt-5.1-codex-max"}
t.Cleanup(func() {
settings.PassThroughRequestEnabled = oldPassThrough
settings.ThinkingModelBlacklist = oldBlacklist
settings.EffortTailModelIDs = oldEffortTailModels
})
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("POST", "/v1/chat/completions", nil)
if mapping != nil {
encoded, err := common.Marshal(mapping)
require.NoError(t, err)
c.Set("model_mapping", string(encoded))
}
info := &relaycommon.RelayInfo{
OriginModelName: request.Model,
Request: request,
RelayFormat: types.RelayFormatOpenAI,
IsStream: request.IsStream(nil),
ChannelMeta: &relaycommon.ChannelMeta{
ChannelType: channelType,
UpstreamModelName: request.Model,
SupportStreamOptions: true,
},
}
require.NoError(t, helper.ModelMappedHelper(c, info, request))
require.NoError(t, helper.ApplyReasoningModelSuffix(c, info, request))
var converted any
var err error
if channelType == constant.ChannelTypeAli {
converted, err = (&ali.Adaptor{}).ConvertOpenAIRequest(c, info, request)
} else {
converted, err = (&openai.Adaptor{}).ConvertOpenAIRequest(c, info, request)
}
require.NoError(t, err)
encoded, err := common.Marshal(converted)
require.NoError(t, err)
return encoded
}
func TestChannelTestOpenAIChatCompatibility(t *testing.T) {
for _, tt := range []struct {
name string
model string
upstream string
endpoint string
channelType int
stream bool
wantLimit string
}{
{name: "GPT6 automatic", model: "gpt-6-astra", upstream: "gpt-6-astra", channelType: constant.ChannelTypeOpenAI, wantLimit: "max_completion_tokens"},
{name: "GPT6 explicit Azure stream", model: "gpt-6-astra", upstream: "gpt-6-astra", endpoint: string(constant.EndpointTypeOpenAI), channelType: constant.ChannelTypeAzure, stream: true, wantLimit: "max_completion_tokens"},
{name: "GPT6 sol", model: "gpt-6-sol", upstream: "gpt-6-sol", channelType: constant.ChannelTypeOpenAI, wantLimit: "max_completion_tokens"},
{name: "GPT6 luna", model: "gpt-6-luna", upstream: "gpt-6-luna", channelType: constant.ChannelTypeOpenAI, wantLimit: "max_completion_tokens"},
{name: "alias maps to GPT6", model: "customer-model", upstream: "gpt-6-astra", channelType: constant.ChannelTypeOpenAI, wantLimit: "max_completion_tokens"},
{name: "GPT5 alias maps to Qwen", model: "gpt-5.6-luna", upstream: "qwen-turbo", channelType: constant.ChannelTypeAli, wantLimit: "max_tokens"},
{name: "GPT5 stream", model: "gpt-5.6-luna", upstream: "gpt-5.6-luna", channelType: constant.ChannelTypeOpenAI, stream: true, wantLimit: "max_completion_tokens"},
{name: "GPT4 explicit", model: "gpt-4.1", upstream: "gpt-4.1", endpoint: string(constant.EndpointTypeOpenAI), channelType: constant.ChannelTypeOpenAI, wantLimit: "max_tokens"},
{name: "o series", model: "o3-mini", upstream: "o3-mini", channelType: constant.ChannelTypeAzure, wantLimit: "max_completion_tokens"},
} {
t.Run(tt.name, func(t *testing.T) {
request, ok := buildTestRequest(tt.model, tt.endpoint, &model.Channel{}, tt.stream).(*dto.GeneralOpenAIRequest)
require.True(t, ok)
encoded := convertChatCompatibilityRequest(t, request, tt.channelType, map[string]string{tt.model: tt.upstream})
want := map[string]any{
"model": tt.upstream,
"messages": []dto.Message{{Role: "user", Content: "hi"}},
"stream": tt.stream,
tt.wantLimit: 16,
}
if tt.stream {
want["stream_options"] = map[string]any{"include_usage": true}
}
wantJSON, err := common.Marshal(want)
require.NoError(t, err)
assert.JSONEq(t, string(wantJSON), string(encoded))
})
}
}
func TestOpenAIChatSamplingCompatibility(t *testing.T) {
const sampling = `{"temperature":0.2,"top_p":0.8,"logprobs":true,"top_logprobs":5}`
for _, tt := range []struct {
name string
model string
effort string
reasoning string
mapping map[string]string
zeroValues bool
wantModel string
wantEffort string
wantRole string
wantParams string
}{
{name: "GPT5.1 explicit none", model: "gpt-5.1", effort: "none", wantEffort: "none", wantRole: "developer", wantParams: sampling},
{name: "GPT5.2 default none", model: "gpt-5.2", wantRole: "developer", wantParams: sampling},
{name: "GPT5.2 dated snapshot", model: "gpt-5.2-2025-12-11", wantRole: "developer", wantParams: sampling},
{name: "GPT5.4 reasoning", model: "gpt-5.4", effort: "high", wantEffort: "high", wantRole: "developer", wantParams: `{}`},
{name: "GPT5.4 snapshot none", model: "gpt-5.4-2026-03-05", effort: "none", wantEffort: "none", wantRole: "developer", wantParams: sampling},
{name: "explicit zero values", model: "gpt-5.4", effort: "none", zeroValues: true, wantEffort: "none", wantRole: "developer", wantParams: `{"temperature":0,"top_p":0,"logprobs":false}`},
{name: "GPT5 original", model: "gpt-5", wantRole: "developer", wantParams: `{}`},
{name: "GPT5.6 existing policy", model: "gpt-5.6-luna", wantRole: "developer", wantParams: `{}`},
{name: "pro variant", model: "gpt-5.2-pro-2025-12-11", wantRole: "developer", wantParams: `{}`},
{name: "chat variant", model: "gpt-5.2-chat-latest", wantRole: "developer", wantParams: `{}`},
{name: "codex model name keeps max", model: "gpt-5.1-codex-max", wantRole: "developer", wantParams: `{}`},
{name: "GPT6", model: "gpt-6-astra", wantRole: "developer", wantParams: `{}`},
{name: "GPT6 snapshot", model: "gpt-6-astra-2026-09-03", wantRole: "developer", wantParams: `{}`},
{name: "GPT6 effort suffix", model: "gpt-6-astra-high", wantModel: "gpt-6-astra", wantEffort: "high", wantRole: "developer", wantParams: `{}`},
{name: "GPT6 sol none keeps sampling", model: "gpt-6-sol", wantRole: "developer", wantParams: sampling},
{name: "GPT6 luna effort drops sampling", model: "gpt-6-luna-high", wantModel: "gpt-6-luna", wantEffort: "high", wantRole: "developer", wantParams: `{}`},
{name: "none effort suffix", model: "gpt-5.2-none", wantModel: "gpt-5.2", wantEffort: "none", wantRole: "developer", wantParams: sampling},
{name: "modifier overrides explicit effort", model: "gpt-5.2@thinking:off", effort: "high", wantModel: "gpt-5.2", wantEffort: "none", wantRole: "developer", wantParams: sampling},
{name: "mapped modifier wins", model: "customer-model@thinking:off", mapping: map[string]string{"customer-model": "gpt-5.2@effort:high"}, wantModel: "gpt-5.2", wantEffort: "high", wantRole: "developer", wantParams: `{}`},
{name: "nested reasoning disabled", model: "gpt-5.2", reasoning: `{"enabled":false}`, wantEffort: "none", wantRole: "developer", wantParams: sampling},
{name: "o1 mini role exception", model: "o1-mini", wantRole: "system", wantParams: `{"top_p":0.8,"logprobs":true,"top_logprobs":5}`},
{name: "GPT4 unchanged", model: "gpt-4.1", wantRole: "system", wantParams: sampling},
{name: "future model unchanged", model: "gpt-7", wantRole: "system", wantParams: sampling},
} {
t.Run(tt.name, func(t *testing.T) {
request := &dto.GeneralOpenAIRequest{
Model: tt.model,
Messages: []dto.Message{
{Role: "system", Content: "first instruction"},
{Role: "system", Content: "second instruction"},
{Role: "user", Content: "hi"},
},
ReasoningEffort: tt.effort,
}
require.NoError(t, common.UnmarshalJsonStr(sampling, request))
if tt.reasoning != "" {
request.Reasoning = []byte(tt.reasoning)
}
if tt.zeroValues {
request.Temperature = lo.ToPtr(0.0)
request.TopP = lo.ToPtr(0.0)
request.LogProbs = lo.ToPtr(false)
request.TopLogProbs = nil
}
encoded := convertChatCompatibilityRequest(t, request, constant.ChannelTypeOpenAI, tt.mapping)
var want map[string]any
require.NoError(t, common.UnmarshalJsonStr(tt.wantParams, &want))
want["model"] = tt.model
if tt.wantModel != "" {
want["model"] = tt.wantModel
}
want["messages"] = []dto.Message{
{Role: tt.wantRole, Content: "first instruction"},
{Role: "system", Content: "second instruction"},
{Role: "user", Content: "hi"},
}
if tt.wantEffort != "" {
want["reasoning_effort"] = tt.wantEffort
}
wantJSON, err := common.Marshal(want)
require.NoError(t, err)
assert.JSONEq(t, string(wantJSON), string(encoded))
})
}
}
func TestOpenAIChatTokenLimitCompatibility(t *testing.T) {
for _, modelName := range []string{"gpt-5", "o3-mini", "gpt-6-astra"} {
for _, tt := range []struct {
name string
input string
want string
}{
{name: "omitted", input: `{}`, want: `{}`},
{name: "legacy only", input: `{"max_tokens":100}`, want: `{"max_completion_tokens":100}`},
{name: "completion only", input: `{"max_completion_tokens":50}`, want: `{"max_completion_tokens":50}`},
{name: "both positive stay present", input: `{"max_tokens":100,"max_completion_tokens":50}`, want: `{"max_tokens":100,"max_completion_tokens":50}`},
{name: "zero completion falls back", input: `{"max_tokens":100,"max_completion_tokens":0}`, want: `{"max_completion_tokens":100}`},
{name: "legacy zero stays present", input: `{"max_tokens":0}`, want: `{"max_tokens":0}`},
{name: "completion zero stays present", input: `{"max_completion_tokens":0}`, want: `{"max_completion_tokens":0}`},
{name: "both zero stay present", input: `{"max_tokens":0,"max_completion_tokens":0}`, want: `{"max_tokens":0,"max_completion_tokens":0}`},
} {
t.Run(modelName+"/"+tt.name, func(t *testing.T) {
request := &dto.GeneralOpenAIRequest{Model: modelName, Messages: []dto.Message{{Role: "user", Content: "hi"}}}
require.NoError(t, common.UnmarshalJsonStr(tt.input, request))
encoded := convertChatCompatibilityRequest(t, request, constant.ChannelTypeOpenAI, nil)
want := dto.GeneralOpenAIRequest{Model: modelName, Messages: []dto.Message{{Role: "user", Content: "hi"}}}
require.NoError(t, common.UnmarshalJsonStr(tt.want, &want))
wantJSON, err := common.Marshal(want)
require.NoError(t, err)
assert.JSONEq(t, string(wantJSON), string(encoded))
})
}
}
}
func TestDirectOpenAIResponsesKeepsExistingParameters(t *testing.T) {
const body = `{"model":"gpt-6-astra","input":"hi","max_output_tokens":100,"temperature":0.2,"top_p":0.8,"top_logprobs":5,"include":["message.output_text.logprobs"],"reasoning":{"effort":"high"}}`
var request dto.OpenAIResponsesRequest
require.NoError(t, common.UnmarshalJsonStr(body, &request))
info := &relaycommon.RelayInfo{
OriginModelName: "gpt-6-astra",
RelayFormat: types.RelayFormatOpenAIResponses,
ChannelMeta: &relaycommon.ChannelMeta{
ChannelType: constant.ChannelTypeOpenAI,
UpstreamModelName: "gpt-6-astra",
},
}
converted, err := (&openai.Adaptor{}).ConvertOpenAIResponsesRequest(nil, info, request)
require.NoError(t, err)
encoded, err := common.Marshal(converted)
require.NoError(t, err)
assert.JSONEq(t, body, string(encoded))
}