Commit 6b659fd6 by CaIon

fix(relay): preserve reasoning effort without implicit remapping

parent 7c044d7c
......@@ -15,6 +15,7 @@ import (
"github.com/QuantumNous/new-api/relay/constant"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/setting/model_setting"
"github.com/QuantumNous/new-api/setting/reasoning"
"github.com/gin-gonic/gin"
)
......@@ -98,6 +99,9 @@ func applyDeepSeekV4OpenAIThinkingSuffix(info *relaycommon.RelayInfo, request *d
if info != nil && info.ChannelMeta != nil && info.UpstreamModelName != "" {
modelName = info.UpstreamModelName
}
if model_setting.ShouldPreserveThinkingSuffix(modelName) || info != nil && model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) {
return nil
}
baseModel, thinkingType, effort, ok := reasoning.ParseDeepSeekV4ThinkingSuffix(modelName)
if !ok {
return nil
......@@ -125,6 +129,9 @@ func applyDeepSeekV4ClaudeThinkingSuffix(info *relaycommon.RelayInfo, request *d
if info != nil && info.ChannelMeta != nil && info.UpstreamModelName != "" {
modelName = info.UpstreamModelName
}
if model_setting.ShouldPreserveThinkingSuffix(modelName) || info != nil && model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) {
return nil
}
baseModel, thinkingType, effort, ok := reasoning.ParseDeepSeekV4ThinkingSuffix(modelName)
if !ok {
return nil
......@@ -170,6 +177,9 @@ func applyDeepSeekV4ResponsesThinkingSuffix(info *relaycommon.RelayInfo, request
if info != nil && info.ChannelMeta != nil && info.UpstreamModelName != "" {
modelName = info.UpstreamModelName
}
if model_setting.ShouldPreserveThinkingSuffix(modelName) || info != nil && model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) {
return
}
baseModel, thinkingType, effort, ok := reasoning.ParseDeepSeekV4ThinkingSuffix(modelName)
if ok {
if thinkingType == "disabled" {
......
......@@ -249,6 +249,18 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
if info.ChannelType != constant.ChannelTypeOpenAI && info.ChannelType != constant.ChannelTypeAzure {
request.StreamOptions = nil
}
// Nested reasoning is an OpenRouter-compatible input dialect and needs
// projection even without a protocol conversion hop. Native top-level
// reasoning_effort stays untouched unless a modifier or conversion applies.
// OpenRouter retains its own dialect normalization below.
preserveSuffix := model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) || model_setting.ShouldPreserveThinkingSuffix(info.UpstreamModelName)
upstreamEffort, _ := reasoning.ParseOpenAIReasoningEffortFromModelSuffix(info.UpstreamModelName)
originEffort, _ := reasoning.ParseOpenAIReasoningEffortFromModelSuffix(info.OriginModelName)
renderReasoning := len(request.Reasoning) > 0 || len(info.RequestConversionChain) > 1 || request.ReasoningConversion != nil || info.ReasoningState() != nil ||
!preserveSuffix && (upstreamEffort != "" || originEffort != "")
if info.ChannelType != constant.ChannelTypeOpenRouter && !renderReasoning {
info.SetReasoningEffort(request.ReasoningEffort)
}
if info.ChannelType == constant.ChannelTypeOpenRouter {
initialIntent, err := kitreasoning.FromOpenAIChat(request)
if err != nil {
......@@ -273,7 +285,6 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
request.Usage = json.RawMessage(`{"include":true}`)
}
// 合并 effort 尾巴产生的意图
preserveSuffix := model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) || model_setting.ShouldPreserveThinkingSuffix(info.UpstreamModelName)
mergeEffortSuffix := func(modelName string) error {
rawEffort, _ := reasoning.ParseOpenAIReasoningEffortFromModelSuffix(modelName)
if rawEffort == "" {
......@@ -374,8 +385,7 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
}
}
if info.ChannelType != constant.ChannelTypeOpenRouter {
preserveSuffix := model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) || model_setting.ShouldPreserveThinkingSuffix(info.UpstreamModelName)
if info.ChannelType != constant.ChannelTypeOpenRouter && renderReasoning {
effort, baseModel := reasoning.ParseOpenAIReasoningEffortFromModelSuffix(info.UpstreamModelName)
if preserveSuffix {
effort = ""
......@@ -412,7 +422,7 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
info.UpstreamModelName = baseModel
request.Model = baseModel
}
if canonicalEffort := kitreasoning.OpenAIEffort(kitreasoning.EffectiveEffort(currentIntent)); canonicalEffort != "" {
if canonicalEffort := kitreasoning.EffectiveEffort(currentIntent); canonicalEffort != "" {
request.ReasoningEffort = string(canonicalEffort)
info.SetReasoningEffort(string(canonicalEffort))
}
......@@ -666,6 +676,21 @@ func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommo
if preserveSuffix {
effort = ""
}
originEffort := ""
if info != nil && !preserveSuffix {
originEffort, _ = reasoning.ParseOpenAIReasoningEffortFromModelSuffix(info.OriginModelName)
}
crossProtocol := info != nil && len(info.RequestConversionChain) > 1
if (info == nil || info.ChannelType != constant.ChannelTypeOpenRouter) && !crossProtocol && effort == "" && originEffort == "" && request.ReasoningConversion == nil && info.ReasoningState() == nil {
if info != nil {
rawEffort := ""
if request.Reasoning != nil {
rawEffort = request.Reasoning.Effort
}
info.SetReasoningEffort(rawEffort)
}
return request, nil
}
currentIntent, err := kitreasoning.FromOpenAIResponses(&request)
if err != nil {
return nil, kitreasoning.AsClientError(err)
......@@ -700,7 +725,7 @@ func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommo
info.UpstreamModelName = originModel
}
}
if canonicalEffort := kitreasoning.OpenAIEffort(kitreasoning.EffectiveEffort(currentIntent)); canonicalEffort != "" {
if canonicalEffort := kitreasoning.EffectiveEffort(currentIntent); canonicalEffort != "" {
if request.Reasoning == nil {
request.Reasoning = &dto.Reasoning{}
}
......
......@@ -308,6 +308,7 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
}
if !model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) &&
!model_setting.ShouldPreserveThinkingSuffix(info.UpstreamModelName) &&
strings.HasSuffix(info.UpstreamModelName, "-thinking") &&
strings.HasPrefix(info.UpstreamModelName, "deepseek") {
info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-thinking")
......
......@@ -11,6 +11,7 @@ import (
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/setting/model_setting"
"github.com/QuantumNous/new-api/relay/constant"
......@@ -78,10 +79,11 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
request.MaxCompletionTokens = request.MaxTokens
request.MaxTokens = nil
}
if strings.HasSuffix(request.Model, "-high") {
preserveSuffix := model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) || model_setting.ShouldPreserveThinkingSuffix(request.Model)
if !preserveSuffix && strings.HasSuffix(request.Model, "-high") {
request.ReasoningEffort = "high"
request.Model = strings.TrimSuffix(request.Model, "-high")
} else if strings.HasSuffix(request.Model, "-low") {
} else if !preserveSuffix && strings.HasSuffix(request.Model, "-low") {
request.ReasoningEffort = "low"
request.Model = strings.TrimSuffix(request.Model, "-low")
}
......
......@@ -496,9 +496,10 @@ func reasoningEffortFromRequest(request dto.Request) string {
}
case *dto.GeminiChatRequest:
if req != nil && req.GenerationConfig.ThinkingConfig != nil {
intent, err := kitreasoning.FromGemini(req)
if err == nil {
effort = string(kitreasoning.EffectiveEffort(intent))
config := req.GenerationConfig.ThinkingConfig
effort = config.ThinkingLevel
if effort == "" && config.ThinkingBudget != nil {
effort = string(kitreasoning.EffortFromBudget(*config.ThinkingBudget))
}
}
}
......
......@@ -211,7 +211,7 @@ func applyModelControls(req dto.Request, parsed parsedModelModifiers) error {
delete(reasoningConfig, "max_tokens")
request.ReasoningEffort = ""
if parsed.intent.Effort != "" {
request.ReasoningEffort = string(reasoning.OpenAIEffort(parsed.intent.Effort))
request.ReasoningEffort = string(parsed.intent.Effort)
}
}
if len(reasoningConfig) == 0 {
......@@ -237,7 +237,7 @@ func applyModelControls(req dto.Request, parsed parsedModelModifiers) error {
if request.Reasoning == nil {
request.Reasoning = &dto.Reasoning{}
}
request.Reasoning.Effort = string(reasoning.OpenAIEffort(parsed.intent.Effort))
request.Reasoning.Effort = string(parsed.intent.Effort)
} else if request.Reasoning != nil && parsed.intent.BudgetTokens == nil {
request.Reasoning.Effort = ""
}
......
......@@ -106,6 +106,9 @@ func ApplyReasoningModelSuffix(c *gin.Context, info *relaycommon.RelayInfo, outb
if info.Request != nil {
info.Request.SetModelName(info.UpstreamModelName)
}
if selected.hasThinking {
info.SetReasoningEffort(string(reasoning.EffectiveEffort(selected.intent)))
}
for i := range diagnostics {
diagnostics[i].From = info.RelayFormat
}
......
......@@ -35,14 +35,19 @@ func ApplyReasoning(ctx context.Context, req *dto.ClaudeRequest, info convmeta.M
// accounting metadata, but do not run the capability renderer or rewrite
// provider-native controls.
if !crossProtocol && source.IsEmpty() && suffix.IsEmpty() {
native, err := reasoning.FromClaude(req)
if err != nil {
return err
}
if info != nil {
if effort := reasoning.EffectiveEffort(native); effort != "" {
info.SetReasoningEffort(string(effort))
effort := req.GetEfforts()
if effort == "" && req.Thinking != nil {
switch {
case req.Thinking.Type == "disabled":
effort = string(reasoning.EffortNone)
case req.Thinking.BudgetTokens != nil:
effort = string(reasoning.EffortFromBudget(*req.Thinking.BudgetTokens))
case req.Thinking.Type == "enabled" || req.Thinking.Type == "adaptive":
effort = string(reasoning.EffortHigh)
}
}
info.SetReasoningEffort(effort)
}
return nil
}
......
......@@ -106,14 +106,15 @@ func ApplyThinkingConfig(geminiRequest *dto.GeminiChatRequest, info convmeta.Met
// modifier, read portable effort metadata without running the capability
// renderer or rewriting provider-native controls.
if !crossProtocol && suffix.IsEmpty() {
native, err := reasoning.FromGemini(geminiRequest)
if err != nil {
return err
}
if info != nil {
if effort := reasoning.EffectiveEffort(native); effort != "" {
info.SetReasoningEffort(string(effort))
effort := ""
if config := geminiRequest.GenerationConfig.ThinkingConfig; config != nil {
effort = config.ThinkingLevel
if effort == "" && config.ThinkingBudget != nil {
effort = string(reasoning.EffortFromBudget(*config.ThinkingBudget))
}
}
info.SetReasoningEffort(effort)
}
return nil
}
......
......@@ -363,8 +363,8 @@ func FromOpenAIChat(req *dto.GeneralOpenAIRequest) (Intent, error) {
BudgetSource: SourcePivot,
}
if req.ReasoningEffort != "" {
projectedEffort := OpenAIEffort(EffectiveEffort(pivot))
if Effort(req.ReasoningEffort) == projectedEffort {
pivotEffort := EffectiveEffort(pivot)
if Effort(req.ReasoningEffort) == pivotEffort {
intent.Effort = ""
intent.Mode = ModeUnset
}
......@@ -384,7 +384,7 @@ func ApplyToOpenAIChat(req *dto.GeneralOpenAIRequest, intent Intent) error {
return err
}
if effort := OpenAIEffort(EffectiveEffort(intent)); effort != "" {
if effort := EffectiveEffort(intent); effort != "" {
req.ReasoningEffort = string(effort)
}
......@@ -412,7 +412,7 @@ func ApplyToOpenAIResponses(req *dto.OpenAIResponsesRequest, intent Intent) erro
return err
}
if effort := OpenAIEffort(EffectiveEffort(intent)); effort != "" {
if effort := EffectiveEffort(intent); effort != "" {
summary := "detailed"
if effort == EffortNone || (intent.IncludeThoughts != nil && !*intent.IncludeThoughts) {
summary = ""
......@@ -436,16 +436,6 @@ func ApplyToOpenAIResponses(req *dto.OpenAIResponsesRequest, intent Intent) erro
return nil
}
// OpenAIEffort maps the canonical cross-provider vocabulary to the public
// OpenAI reasoning_effort vocabulary. Claude/OpenRouter "max" has no direct
// OpenAI equivalent and is represented by xhigh at that wire boundary.
func OpenAIEffort(effort Effort) Effort {
if effort == EffortMax {
return EffortXHigh
}
return effort
}
func FromOpenAIResponses(req *dto.OpenAIResponsesRequest) (Intent, error) {
if req == nil {
return Intent{}, nil
......@@ -481,8 +471,8 @@ func FromOpenAIResponses(req *dto.OpenAIResponsesRequest) (Intent, error) {
BudgetSource: SourcePivot,
}
if req.Reasoning != nil && req.Reasoning.Effort != "" {
projectedEffort := OpenAIEffort(EffectiveEffort(pivot))
if Effort(req.Reasoning.Effort) == projectedEffort {
pivotEffort := EffectiveEffort(pivot)
if Effort(req.Reasoning.Effort) == pivotEffort {
intent.Effort = ""
intent.Mode = ModeUnset
}
......
......@@ -3,6 +3,7 @@ package reasoning
import (
"testing"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
......@@ -123,3 +124,49 @@ func TestIntentStateRoundTrip(t *testing.T) {
func boolPtr(v bool) *bool {
return &v
}
func TestOpenAIPivotRetainsExactStrengthAndBudget(t *testing.T) {
budget, include := 16384, false
for _, effort := range []Effort{EffortMax, EffortXHigh} {
t.Run(string(effort), func(t *testing.T) {
intent := Intent{Mode: ModeEnabled, Effort: effort, BudgetTokens: &budget, IncludeThoughts: &include}
chat := &dto.GeneralOpenAIRequest{}
require.NoError(t, ApplyToOpenAIChat(chat, intent))
assert.Equal(t, string(effort), chat.ReasoningEffort)
restored, err := FromOpenAIChat(chat)
require.NoError(t, err)
assert.Equal(t, effort, restored.Effort)
require.NotNil(t, restored.BudgetTokens)
assert.Equal(t, budget, *restored.BudgetTokens)
require.NotNil(t, restored.IncludeThoughts)
assert.False(t, *restored.IncludeThoughts)
responses := &dto.OpenAIResponsesRequest{}
require.NoError(t, ApplyToOpenAIResponses(responses, restored))
require.NotNil(t, responses.Reasoning)
assert.Equal(t, string(effort), responses.Reasoning.Effort)
restored, err = FromOpenAIResponses(responses)
require.NoError(t, err)
assert.Equal(t, effort, restored.Effort)
require.NotNil(t, restored.BudgetTokens)
assert.Equal(t, budget, *restored.BudgetTokens)
require.NotNil(t, restored.IncludeThoughts)
assert.False(t, *restored.IncludeThoughts)
})
}
}
func TestOpenAIPivotDoesNotTreatMaxAndXHighAsEquivalent(t *testing.T) {
intent := Intent{Mode: ModeEnabled, Effort: EffortMax}
chat := &dto.GeneralOpenAIRequest{}
require.NoError(t, ApplyToOpenAIChat(chat, intent))
chat.ReasoningEffort = "xhigh"
_, err := FromOpenAIChat(chat)
require.ErrorIs(t, err, ErrEffortConflict)
responses := &dto.OpenAIResponsesRequest{}
require.NoError(t, ApplyToOpenAIResponses(responses, intent))
responses.Reasoning.Effort = "xhigh"
_, err = FromOpenAIResponses(responses)
require.ErrorIs(t, err, ErrEffortConflict)
}
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