Commit 49ec4696 by PDMaker Committed by GitHub

fix(relay): apply model-specific OpenAI chat capabilities (#7211)

* fix(relay): treat gpt-5 and later generations alike for max_completion_tokens

IsOpenAIGPT5Model matched on the literal prefix "gpt-5", so gpt-6-astra
(and every generation after it) fell through the gpt-5 request rules:
max_tokens was forwarded as-is and the provider rejected it with
"Unsupported parameter: 'max_tokens' is not supported with this model.
Use 'max_completion_tokens' instead." The same gap left temperature,
top_p and logprobs untouched, each of which the provider also rejects,
and made the channel test button report a 400 for a healthy deployment.

Match on the major version instead (gpt-<n>... with n >= 5). Callers are
unchanged: ConvertOpenAIRequest, GetSystemRoleName, buildTestRequest and
the health check all go through this one helper. buildTestRequest now
sends max_completion_tokens for these models directly instead of relying
on the later conversion. gpt-4.1, gpt-4o, gpt-oss, gpt-image and
gpt-realtime names still do not match.

Verified against Azure OpenAI gpt-6-astra (2026-09-03): with the old
prefix max_tokens / temperature / top_p / logprobs each returned 400,
while gpt-5.6-luna with the same payload returned 200.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bwz8o5UeoRtrtDusKaayp

* fix(relay): separate OpenAI chat model compatibility rules

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: CaIon <i@caion.me>
parent 2cf177ac
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))
}
......@@ -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
}
......
......@@ -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"
......
......@@ -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))
})
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment