diff --git a/controller/channel_test_request_test.go b/controller/channel_test_request_test.go new file mode 100644 index 000000000000..fc6058626556 --- /dev/null +++ b/controller/channel_test_request_test.go @@ -0,0 +1,236 @@ +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: "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: "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)) +} diff --git a/relay/channel/openai/adaptor.go b/relay/channel/openai/adaptor.go index f4dce3ad49b7..cafe1fd705c6 100644 --- a/relay/channel/openai/adaptor.go +++ b/relay/channel/openai/adaptor.go @@ -357,34 +357,6 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn info.SetReasoningEffort(string(effectiveEffort)) } - isOModel := dto.IsOpenAIReasoningOModel(info.UpstreamModelName) - isGPT5Model := dto.IsOpenAIGPT5Model(info.UpstreamModelName) - if isOModel || isGPT5Model { - if lo.FromPtrOr(request.MaxCompletionTokens, uint(0)) == 0 && lo.FromPtrOr(request.MaxTokens, uint(0)) != 0 { - request.MaxCompletionTokens = request.MaxTokens - request.MaxTokens = nil - } - - if isOModel { - request.Temperature = nil - } - - // gpt-5系列模型适配 归零不再支持的参数 - if isGPT5Model { - request.Temperature = nil - request.TopP = nil - request.LogProbs = nil - } - - // o系列模型developer适配(o1-mini除外) - if !strings.HasPrefix(info.UpstreamModelName, "o1-mini") && !strings.HasPrefix(info.UpstreamModelName, "o1-preview") { - //修改第一个Message的内容,将system改为developer - if len(request.Messages) > 0 && request.Messages[0].Role == "system" { - request.Messages[0].Role = "developer" - } - } - } - if info.ChannelType != constant.ChannelTypeOpenRouter && renderReasoning { effort, baseModel := reasoning.ParseOpenAIReasoningEffortFromModelSuffix(info.UpstreamModelName) if preserveSuffix { @@ -431,6 +403,27 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn } } + capabilities := dto.GetOpenAIChatCapabilities(info.UpstreamModelName, info.ReasoningEffort) + if capabilities.UseMaxCompletionTokens { + if lo.FromPtrOr(request.MaxCompletionTokens, uint(0)) == 0 && lo.FromPtrOr(request.MaxTokens, uint(0)) != 0 { + request.MaxCompletionTokens = request.MaxTokens + request.MaxTokens = nil + } + } + if !capabilities.SupportsTemperature { + request.Temperature = nil + } + if !capabilities.SupportsTopP { + request.TopP = nil + } + if !capabilities.SupportsLogProbs { + request.LogProbs = nil + request.TopLogProbs = nil + } + if capabilities.UseDeveloperRole && len(request.Messages) > 0 && request.Messages[0].Role == "system" { + request.Messages[0].Role = "developer" + } + return request, nil } diff --git a/relaykit/dto/openai_request.go b/relaykit/dto/openai_request.go index 1d89361e90a9..d76d492ccee7 100644 --- a/relaykit/dto/openai_request.go +++ b/relaykit/dto/openai_request.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "strings" + "time" kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil" "github.com/QuantumNous/new-api/relaykit/types" @@ -232,8 +233,74 @@ func IsOpenAIReasoningOModel(modelName string) bool { strings.HasPrefix(modelName, "o4") } +// IsOpenAIGPT5Model identifies the GPT-5 family, independently of request capabilities. func IsOpenAIGPT5Model(modelName string) bool { - return strings.HasPrefix(modelName, "gpt-5") + return modelName == "gpt-5" || strings.HasPrefix(modelName, "gpt-5-") || strings.HasPrefix(modelName, "gpt-5.") +} + +// OpenAIChatCapabilities describes independent Chat Completions compatibility rules. +type OpenAIChatCapabilities struct { + UseMaxCompletionTokens bool + UseDeveloperRole bool + SupportsTemperature bool + SupportsTopP bool + SupportsLogProbs bool // Also governs top_logprobs. +} + +// GetOpenAIChatCapabilities uses the mapped model and resolved reasoning effort. +// Unrecognized models retain their parameters; future GPT generations do not +// automatically inherit the restrictions of existing models. +func GetOpenAIChatCapabilities(modelName, reasoningEffort string) OpenAIChatCapabilities { + capabilities := OpenAIChatCapabilities{ + SupportsTemperature: true, + SupportsTopP: true, + SupportsLogProbs: true, + } + if IsOpenAIReasoningOModel(modelName) { + capabilities.UseMaxCompletionTokens = true + capabilities.UseDeveloperRole = !strings.HasPrefix(modelName, "o1-mini") && !strings.HasPrefix(modelName, "o1-preview") + capabilities.SupportsTemperature = false + return capabilities + } + + isGPT5Model := IsOpenAIGPT5Model(modelName) + if !isGPT5Model && !isOpenAIModelSnapshot(modelName, "gpt-6-astra") { + return capabilities + } + capabilities.UseMaxCompletionTokens = true + capabilities.UseDeveloperRole = true + + // These standard GPT-5 models default to none and support sampling only + // without reasoning. Named variants (pro, codex, chat-latest, etc.) do not + // inherit this exception. GPT-6 Astra never supports these parameters. + // https://developers.openai.com/api/docs/guides/latest-model?model=gpt-5.2 + // https://developers.openai.com/api/docs/guides/latest-model?model=gpt-5.4 + // https://developers.openai.com/api/docs/guides/latest-model?model=gpt-6-astra + supportsSampling := false + if isGPT5Model && (reasoningEffort == "" || reasoningEffort == "none") { + for _, model := range []string{"gpt-5.1", "gpt-5.2", "gpt-5.4"} { + if isOpenAIModelSnapshot(modelName, model) { + supportsSampling = true + break + } + } + } + capabilities.SupportsTemperature = supportsSampling + capabilities.SupportsTopP = supportsSampling + capabilities.SupportsLogProbs = supportsSampling + return capabilities +} + +func isOpenAIModelSnapshot(modelName, baseModel string) bool { + if modelName == baseModel { + return true + } + snapshot, ok := strings.CutPrefix(modelName, baseModel+"-") + if !ok { + return false + } + _, err := time.Parse(time.DateOnly, snapshot) + return err == nil } func IsQwenThinkingBudgetModel(modelName string) bool { @@ -245,11 +312,7 @@ func IsQwenThinkingBudgetModel(modelName string) bool { } func (r *GeneralOpenAIRequest) GetSystemRoleName() string { - if IsOpenAIReasoningOModel(r.Model) { - if !strings.HasPrefix(r.Model, "o1-mini") && !strings.HasPrefix(r.Model, "o1-preview") { - return "developer" - } - } else if IsOpenAIGPT5Model(r.Model) { + if GetOpenAIChatCapabilities(r.Model, r.ReasoningEffort).UseDeveloperRole { return "developer" } return "system" diff --git a/relaykit/dto/openai_request_zero_value_test.go b/relaykit/dto/openai_request_zero_value_test.go index 8ab95102817e..2e67c257ab41 100644 --- a/relaykit/dto/openai_request_zero_value_test.go +++ b/relaykit/dto/openai_request_zero_value_test.go @@ -195,6 +195,13 @@ func TestGeneralOpenAIRequestGetSystemRoleName(t *testing.T) { {name: "o1 mini stays system", model: "o1-mini", want: "system"}, {name: "o1 preview stays system", model: "o1-preview", want: "system"}, {name: "gpt 5 uses developer", model: "gpt-5", want: "developer"}, + {name: "gpt 5.6 uses developer", model: "gpt-5.6-luna", want: "developer"}, + {name: "gpt 6 uses developer", model: "gpt-6-astra", want: "developer"}, + {name: "gpt 6 snapshot uses developer", model: "gpt-6-astra-2026-09-03", want: "developer"}, + {name: "unknown gpt 6 variant stays system", model: "gpt-6-astra-pro", want: "system"}, + {name: "invalid gpt 6 snapshot stays system", model: "gpt-6-astra-2026-99-03", want: "system"}, + {name: "unknown generation stays system", model: "gpt-7", want: "system"}, + {name: "gpt 4.1 stays system", model: "gpt-4.1-nano", want: "system"}, {name: "omni is not o series", model: "omni-moderation-latest", want: "system"}, } @@ -202,7 +209,42 @@ func TestGeneralOpenAIRequestGetSystemRoleName(t *testing.T) { t.Run(tt.name, func(t *testing.T) { req := GeneralOpenAIRequest{Model: tt.model} - require.Equal(t, tt.want, req.GetSystemRoleName()) + assert.Equal(t, tt.want, req.GetSystemRoleName()) + }) + } +} + +func TestIsOpenAIGPT5Model(t *testing.T) { + tests := []struct { + model string + want bool + }{ + {model: "gpt-5", want: true}, + {model: "gpt-5-mini", want: true}, + {model: "gpt-5-chat-latest", want: true}, + {model: "gpt-5.6-luna", want: true}, + {model: "gpt-5.4-nano", want: true}, + {model: "gpt-5.2-2025-12-11", want: true}, + {model: "gpt-6-astra", want: false}, + {model: "gpt-50", want: false}, + {model: "gpt-5custom", want: false}, + {model: " GPT-5 ", want: false}, + {model: "gpt-4.1", want: false}, + {model: "gpt-4.1-nano", want: false}, + {model: "gpt-4o", want: false}, + {model: "gpt-4.5-preview", want: false}, + {model: "gpt-oss-120b", want: false}, + {model: "gpt-image-2", want: false}, + {model: "gpt-realtime-2.1", want: false}, + {model: "chatgpt-4o-latest", want: false}, + {model: "o3-mini", want: false}, + {model: "gpt-", want: false}, + {model: "", want: false}, + } + + for _, tt := range tests { + t.Run(tt.model, func(t *testing.T) { + assert.Equal(t, tt.want, IsOpenAIGPT5Model(tt.model)) }) } }