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
...@@ -357,34 +357,6 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn ...@@ -357,34 +357,6 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
info.SetReasoningEffort(string(effectiveEffort)) 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 { if info.ChannelType != constant.ChannelTypeOpenRouter && renderReasoning {
effort, baseModel := reasoning.ParseOpenAIReasoningEffortFromModelSuffix(info.UpstreamModelName) effort, baseModel := reasoning.ParseOpenAIReasoningEffortFromModelSuffix(info.UpstreamModelName)
if preserveSuffix { if preserveSuffix {
...@@ -431,6 +403,27 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn ...@@ -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 return request, nil
} }
......
...@@ -5,6 +5,7 @@ import ( ...@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"strings" "strings"
"time"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil" kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/types" "github.com/QuantumNous/new-api/relaykit/types"
...@@ -232,8 +233,74 @@ func IsOpenAIReasoningOModel(modelName string) bool { ...@@ -232,8 +233,74 @@ func IsOpenAIReasoningOModel(modelName string) bool {
strings.HasPrefix(modelName, "o4") strings.HasPrefix(modelName, "o4")
} }
// IsOpenAIGPT5Model identifies the GPT-5 family, independently of request capabilities.
func IsOpenAIGPT5Model(modelName string) bool { 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 { func IsQwenThinkingBudgetModel(modelName string) bool {
...@@ -245,11 +312,7 @@ func IsQwenThinkingBudgetModel(modelName string) bool { ...@@ -245,11 +312,7 @@ func IsQwenThinkingBudgetModel(modelName string) bool {
} }
func (r *GeneralOpenAIRequest) GetSystemRoleName() string { func (r *GeneralOpenAIRequest) GetSystemRoleName() string {
if IsOpenAIReasoningOModel(r.Model) { if GetOpenAIChatCapabilities(r.Model, r.ReasoningEffort).UseDeveloperRole {
if !strings.HasPrefix(r.Model, "o1-mini") && !strings.HasPrefix(r.Model, "o1-preview") {
return "developer"
}
} else if IsOpenAIGPT5Model(r.Model) {
return "developer" return "developer"
} }
return "system" return "system"
......
...@@ -195,6 +195,13 @@ func TestGeneralOpenAIRequestGetSystemRoleName(t *testing.T) { ...@@ -195,6 +195,13 @@ func TestGeneralOpenAIRequestGetSystemRoleName(t *testing.T) {
{name: "o1 mini stays system", model: "o1-mini", want: "system"}, {name: "o1 mini stays system", model: "o1-mini", want: "system"},
{name: "o1 preview stays system", model: "o1-preview", 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 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"}, {name: "omni is not o series", model: "omni-moderation-latest", want: "system"},
} }
...@@ -202,7 +209,42 @@ func TestGeneralOpenAIRequestGetSystemRoleName(t *testing.T) { ...@@ -202,7 +209,42 @@ func TestGeneralOpenAIRequestGetSystemRoleName(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
req := GeneralOpenAIRequest{Model: tt.model} 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