mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-10-28 08:35:33 +08:00
241 lines
12 KiB
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))
|
|
}
|