fix: record reasoning effort consistently in usage logs (#6641)
This commit is contained in:
@@ -115,7 +115,7 @@ func applyDeepSeekV4OpenAIThinkingSuffix(info *relaycommon.RelayInfo, request *d
|
||||
if info.ChannelMeta != nil {
|
||||
info.UpstreamModelName = baseModel
|
||||
}
|
||||
info.ReasoningEffort = effort
|
||||
info.SetReasoningEffort(effort)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -146,7 +146,7 @@ func applyDeepSeekV4ClaudeThinkingSuffix(info *relaycommon.RelayInfo, request *d
|
||||
if info.ChannelMeta != nil {
|
||||
info.UpstreamModelName = baseModel
|
||||
}
|
||||
info.ReasoningEffort = effort
|
||||
info.SetReasoningEffort(effort)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -185,7 +185,7 @@ func applyDeepSeekV4ResponsesThinkingSuffix(info *relaycommon.RelayInfo, request
|
||||
}
|
||||
}
|
||||
if info != nil && request.Reasoning != nil {
|
||||
info.ReasoningEffort = request.Reasoning.Effort
|
||||
info.SetReasoningEffort(request.Reasoning.Effort)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -352,7 +352,7 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
|
||||
request.Model = originModel
|
||||
}
|
||||
|
||||
info.ReasoningEffort = request.ReasoningEffort
|
||||
info.SetReasoningEffort(request.ReasoningEffort)
|
||||
|
||||
// o系列模型developer适配(o1-mini除外)
|
||||
if !strings.HasPrefix(info.UpstreamModelName, "o1-mini") && !strings.HasPrefix(info.UpstreamModelName, "o1-preview") {
|
||||
@@ -615,7 +615,7 @@ func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommo
|
||||
request.Model = originModel
|
||||
}
|
||||
if info != nil && request.Reasoning != nil && request.Reasoning.Effort != "" {
|
||||
info.ReasoningEffort = request.Reasoning.Effort
|
||||
info.SetReasoningEffort(request.Reasoning.Effort)
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
|
||||
request.ReasoningEffort = "low"
|
||||
request.Model = strings.TrimSuffix(request.Model, "-low")
|
||||
}
|
||||
info.ReasoningEffort = request.ReasoningEffort
|
||||
info.SetReasoningEffort(request.ReasoningEffort)
|
||||
info.UpstreamModelName = request.Model
|
||||
}
|
||||
return request, nil
|
||||
|
||||
@@ -106,6 +106,11 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
|
||||
}
|
||||
info.UpstreamModelName = request.Model
|
||||
}
|
||||
if !model_setting.GetGlobalSettings().PassThroughRequestEnabled && !info.ChannelSetting.PassThroughBodyEnabled {
|
||||
if effort := request.GetEfforts(); effort != "" {
|
||||
info.SetReasoningEffort(effort)
|
||||
}
|
||||
}
|
||||
|
||||
if info.ChannelSetting.SystemPrompt != "" {
|
||||
if request.System == nil {
|
||||
|
||||
@@ -30,6 +30,11 @@ var paramOverrideSensitivePathPrefixes = []string{
|
||||
"model",
|
||||
"original_model",
|
||||
"upstream_model",
|
||||
"reasoning",
|
||||
"reasoning_effort",
|
||||
"output_config",
|
||||
"generationConfig.thinkingConfig",
|
||||
"generation_config.thinking_config",
|
||||
"service_tier",
|
||||
"inference_geo",
|
||||
"speed",
|
||||
@@ -191,6 +196,7 @@ func ApplyParamOverrideWithRelayInfo(jsonData []byte, info *RelayInfo) ([]byte,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
syncReasoningEffortAfterParamOverride(info, jsonData, result)
|
||||
syncRuntimeHeaderOverrideFromContext(info, overrideCtx)
|
||||
if info != nil {
|
||||
if recorder != nil {
|
||||
@@ -202,6 +208,51 @@ func ApplyParamOverrideWithRelayInfo(jsonData []byte, info *RelayInfo) ([]byte,
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func syncReasoningEffortAfterParamOverride(info *RelayInfo, before, after []byte) {
|
||||
if info == nil {
|
||||
return
|
||||
}
|
||||
_, existedBefore := extractReasoningEffortFromJSON(info.GetFinalRequestRelayFormat(), before)
|
||||
effort, existsAfter := extractReasoningEffortFromJSON(info.GetFinalRequestRelayFormat(), after)
|
||||
if existsAfter {
|
||||
info.SetReasoningEffort(effort)
|
||||
return
|
||||
}
|
||||
if existedBefore {
|
||||
info.SetReasoningEffort("")
|
||||
}
|
||||
}
|
||||
|
||||
func extractReasoningEffortFromJSON(format types.RelayFormat, data []byte) (string, bool) {
|
||||
var paths []string
|
||||
switch format {
|
||||
case types.RelayFormatOpenAI:
|
||||
paths = []string{"reasoning_effort", "reasoning.effort"}
|
||||
case types.RelayFormatOpenAIResponses:
|
||||
paths = []string{"reasoning.effort"}
|
||||
case types.RelayFormatClaude:
|
||||
paths = []string{"output_config.effort"}
|
||||
case types.RelayFormatGemini:
|
||||
paths = []string{
|
||||
"generationConfig.thinkingConfig.thinkingLevel",
|
||||
"generation_config.thinking_config.thinking_level",
|
||||
}
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
for _, path := range paths {
|
||||
value := gjson.GetBytes(data, path)
|
||||
if !value.Exists() {
|
||||
continue
|
||||
}
|
||||
if value.Type != gjson.String {
|
||||
return "", true
|
||||
}
|
||||
return strings.TrimSpace(value.String()), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func shouldEnableParamOverrideAudit(paramOverride map[string]interface{}) bool {
|
||||
if common.DebugEnabled {
|
||||
return true
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/setting/model_setting"
|
||||
"github.com/samber/lo"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -2302,3 +2303,113 @@ func assertJSONEqual(t *testing.T, want, got string) {
|
||||
t.Fatalf("json not equal\nwant: %s\ngot: %s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyParamOverrideWithRelayInfoSynchronizesReasoningEffort(t *testing.T) {
|
||||
originalDebugEnabled := common2.DebugEnabled
|
||||
common2.DebugEnabled = false
|
||||
t.Cleanup(func() {
|
||||
common2.DebugEnabled = originalDebugEnabled
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
relayFormat types.RelayFormat
|
||||
initialEffort string
|
||||
input string
|
||||
operation map[string]interface{}
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "Responses set",
|
||||
relayFormat: types.RelayFormatOpenAIResponses,
|
||||
initialEffort: "high",
|
||||
input: `{"reasoning":{"effort":"high"}}`,
|
||||
operation: map[string]interface{}{"mode": "set", "path": "reasoning.effort", "value": "max"},
|
||||
expected: "max",
|
||||
},
|
||||
{
|
||||
name: "chat delete",
|
||||
relayFormat: types.RelayFormatOpenAI,
|
||||
initialEffort: "high",
|
||||
input: `{"reasoning_effort":"high"}`,
|
||||
operation: map[string]interface{}{"mode": "delete", "path": "reasoning_effort"},
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "OpenRouter nested set",
|
||||
relayFormat: types.RelayFormatOpenAI,
|
||||
initialEffort: "medium",
|
||||
input: `{"reasoning":{"effort":"medium"}}`,
|
||||
operation: map[string]interface{}{"mode": "set", "path": "reasoning.effort", "value": "xhigh"},
|
||||
expected: "xhigh",
|
||||
},
|
||||
{
|
||||
name: "Claude output config set",
|
||||
relayFormat: types.RelayFormatClaude,
|
||||
initialEffort: "high",
|
||||
input: `{"output_config":{"effort":"high"}}`,
|
||||
operation: map[string]interface{}{"mode": "set", "path": "output_config.effort", "value": "max"},
|
||||
expected: "max",
|
||||
},
|
||||
{
|
||||
name: "Gemini thinking level set",
|
||||
relayFormat: types.RelayFormatGemini,
|
||||
initialEffort: "medium",
|
||||
input: `{"generationConfig":{"thinkingConfig":{"thinkingLevel":"medium"}}}`,
|
||||
operation: map[string]interface{}{"mode": "set", "path": "generationConfig.thinkingConfig.thinkingLevel", "value": "high"},
|
||||
expected: "high",
|
||||
},
|
||||
{
|
||||
name: "non-string value clears effort",
|
||||
relayFormat: types.RelayFormatOpenAIResponses,
|
||||
initialEffort: "high",
|
||||
input: `{"reasoning":{"effort":"high"}}`,
|
||||
operation: map[string]interface{}{"mode": "set", "path": "reasoning.effort", "value": 42},
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "unrelated override preserves converter-derived effort",
|
||||
relayFormat: types.RelayFormatClaude,
|
||||
initialEffort: "high",
|
||||
input: `{"thinking":{"type":"adaptive"},"max_tokens":4096}`,
|
||||
operation: map[string]interface{}{"mode": "set", "path": "max_tokens", "value": 8192},
|
||||
expected: "high",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
info := &RelayInfo{
|
||||
RelayFormat: tt.relayFormat,
|
||||
ReasoningEffort: tt.initialEffort,
|
||||
ChannelMeta: &ChannelMeta{ParamOverride: map[string]interface{}{
|
||||
"operations": []interface{}{tt.operation},
|
||||
}},
|
||||
}
|
||||
|
||||
_, err := ApplyParamOverrideWithRelayInfo([]byte(tt.input), info)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.expected, info.ReasoningEffort)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReasoningEffortOverrideIsAuditedWithoutDebugMode(t *testing.T) {
|
||||
originalDebugEnabled := common2.DebugEnabled
|
||||
common2.DebugEnabled = false
|
||||
t.Cleanup(func() {
|
||||
common2.DebugEnabled = originalDebugEnabled
|
||||
})
|
||||
info := &RelayInfo{
|
||||
RelayFormat: types.RelayFormatOpenAIResponses,
|
||||
ChannelMeta: &ChannelMeta{ParamOverride: map[string]interface{}{
|
||||
"operations": []interface{}{
|
||||
map[string]interface{}{"mode": "set", "path": "reasoning.effort", "value": "max"},
|
||||
},
|
||||
}},
|
||||
}
|
||||
|
||||
_, err := ApplyParamOverrideWithRelayInfo([]byte(`{"reasoning":{"effort":"high"}}`), info)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"set reasoning.effort = max"}, info.ParamOverrideAudit)
|
||||
}
|
||||
|
||||
@@ -234,6 +234,11 @@ func (info *RelayInfo) InitChannelMeta(c *gin.Context) {
|
||||
// Channel identity feeds the converter options snapshot (e.g.
|
||||
// OpenRouterDialect); drop the cache so a cross-channel retry rebuilds it.
|
||||
info.convOptions = nil
|
||||
if model_setting.GetGlobalSettings().PassThroughRequestEnabled || channelMeta.ChannelSetting.PassThroughBodyEnabled {
|
||||
info.ReasoningEffort = ""
|
||||
} else {
|
||||
info.ReasoningEffort = reasoningEffortFromRequest(info.Request)
|
||||
}
|
||||
|
||||
// reset some fields based on channel meta
|
||||
// 重置某些字段,例如模型名称等
|
||||
@@ -435,6 +440,36 @@ func GenRelayInfoOpenAI(c *gin.Context, request dto.Request) *RelayInfo {
|
||||
return info
|
||||
}
|
||||
|
||||
func reasoningEffortFromRequest(request dto.Request) string {
|
||||
var effort string
|
||||
switch req := request.(type) {
|
||||
case *dto.GeneralOpenAIRequest:
|
||||
if req == nil {
|
||||
return ""
|
||||
}
|
||||
effort = req.ReasoningEffort
|
||||
if strings.TrimSpace(effort) == "" && len(req.Reasoning) > 0 {
|
||||
value := gjson.GetBytes(req.Reasoning, "effort")
|
||||
if value.Type == gjson.String {
|
||||
effort = value.String()
|
||||
}
|
||||
}
|
||||
case *dto.OpenAIResponsesRequest:
|
||||
if req != nil && req.Reasoning != nil {
|
||||
effort = req.Reasoning.Effort
|
||||
}
|
||||
case *dto.ClaudeRequest:
|
||||
if req != nil {
|
||||
effort = req.GetEfforts()
|
||||
}
|
||||
case *dto.GeminiChatRequest:
|
||||
if req != nil && req.GenerationConfig.ThinkingConfig != nil {
|
||||
effort = req.GenerationConfig.ThinkingConfig.ThinkingLevel
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(effort)
|
||||
}
|
||||
|
||||
func genBaseRelayInfo(c *gin.Context, request dto.Request) *RelayInfo {
|
||||
|
||||
//channelType := common.GetContextKeyInt(c, constant.ContextKeyChannelType)
|
||||
@@ -465,8 +500,10 @@ func genBaseRelayInfo(c *gin.Context, request dto.Request) *RelayInfo {
|
||||
if reqId == "" {
|
||||
reqId = common.NewRequestId()
|
||||
}
|
||||
reasoningEffort := reasoningEffortFromRequest(request)
|
||||
info := &RelayInfo{
|
||||
Request: request,
|
||||
Request: request,
|
||||
ReasoningEffort: reasoningEffort,
|
||||
|
||||
RequestId: reqId,
|
||||
UserId: common.GetContextKeyInt(c, constant.ContextKeyUserId),
|
||||
@@ -740,7 +777,7 @@ func (info *RelayInfo) SetReasoningEffort(effort string) {
|
||||
if info == nil {
|
||||
return
|
||||
}
|
||||
info.ReasoningEffort = effort
|
||||
info.ReasoningEffort = strings.TrimSpace(effort)
|
||||
}
|
||||
|
||||
func (info *RelayInfo) EnsureClaudeConvertInfo() *convmeta.ClaudeConvertInfo {
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -78,3 +82,97 @@ func TestRelayInfoMetaTypedNilReceiver(t *testing.T) {
|
||||
assert.NotNil(t, firstOptions.Gemini.SafetySetting)
|
||||
assert.NotNil(t, firstOptions.PreserveThinkingSuffix)
|
||||
}
|
||||
|
||||
func TestGenRelayInfoCapturesRequestReasoningEffort(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
relayFormat types.RelayFormat
|
||||
request dto.Request
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "OpenAI chat top-level effort",
|
||||
path: "/v1/chat/completions",
|
||||
relayFormat: types.RelayFormatOpenAI,
|
||||
request: &dto.GeneralOpenAIRequest{Model: "gpt-5.6-sol", ReasoningEffort: " high "},
|
||||
expected: "high",
|
||||
},
|
||||
{
|
||||
name: "OpenRouter nested chat effort",
|
||||
path: "/v1/chat/completions",
|
||||
relayFormat: types.RelayFormatOpenAI,
|
||||
request: &dto.GeneralOpenAIRequest{Model: "anthropic/claude", Reasoning: json.RawMessage(`{"effort":"xhigh"}`)},
|
||||
expected: "xhigh",
|
||||
},
|
||||
{
|
||||
name: "OpenAI Responses effort",
|
||||
path: "/v1/responses",
|
||||
relayFormat: types.RelayFormatOpenAIResponses,
|
||||
request: &dto.OpenAIResponsesRequest{Model: "gpt-5.6-sol", Reasoning: &dto.Reasoning{Effort: "max"}},
|
||||
expected: "max",
|
||||
},
|
||||
{
|
||||
name: "explicit none is preserved",
|
||||
path: "/v1/responses",
|
||||
relayFormat: types.RelayFormatOpenAIResponses,
|
||||
request: &dto.OpenAIResponsesRequest{Model: "gpt-5.6-sol", Reasoning: &dto.Reasoning{Effort: "none"}},
|
||||
expected: "none",
|
||||
},
|
||||
{
|
||||
name: "non-string nested effort is ignored",
|
||||
path: "/v1/chat/completions",
|
||||
relayFormat: types.RelayFormatOpenAI,
|
||||
request: &dto.GeneralOpenAIRequest{Model: "anthropic/claude", Reasoning: json.RawMessage(`{"effort":42}`)},
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "Claude output config effort",
|
||||
path: "/v1/messages",
|
||||
relayFormat: types.RelayFormatClaude,
|
||||
request: &dto.ClaudeRequest{Model: "claude-opus-4-7", OutputConfig: json.RawMessage(`{"effort":"medium"}`)},
|
||||
expected: "medium",
|
||||
},
|
||||
{
|
||||
name: "Gemini thinking level",
|
||||
path: "/v1beta/models/gemini-3-pro:generateContent",
|
||||
relayFormat: types.RelayFormatGemini,
|
||||
request: &dto.GeminiChatRequest{GenerationConfig: dto.GeminiChatGenerationConfig{
|
||||
ThinkingConfig: &dto.GeminiThinkingConfig{ThinkingLevel: "low"},
|
||||
}},
|
||||
expected: "low",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
ctx.Request = httptest.NewRequest("POST", tt.path, nil)
|
||||
|
||||
info, err := GenRelayInfo(ctx, tt.relayFormat, tt.request, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.expected, info.ReasoningEffort)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitChannelMetaRestoresRequestReasoningEffortForRetry(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
ctx.Request = httptest.NewRequest("POST", "/v1/responses", nil)
|
||||
request := &dto.OpenAIResponsesRequest{
|
||||
Model: "gpt-5.6-sol",
|
||||
Reasoning: &dto.Reasoning{Effort: "max"},
|
||||
}
|
||||
info, err := GenRelayInfo(ctx, types.RelayFormatOpenAIResponses, request, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
info.SetReasoningEffort("high")
|
||||
info.InitChannelMeta(ctx)
|
||||
assert.Equal(t, "max", info.ReasoningEffort)
|
||||
|
||||
info.SetReasoningEffort("low")
|
||||
info.InitChannelMeta(ctx)
|
||||
assert.Equal(t, "max", info.ReasoningEffort)
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ import {
|
||||
isViolationFeeLog,
|
||||
getFirstResponseTimeColor,
|
||||
getResponseTimeColor,
|
||||
getReasoningEffortVariant,
|
||||
renderAuditContent,
|
||||
} from '../../lib/format'
|
||||
import {
|
||||
@@ -604,12 +605,9 @@ export function DetailsDialog(props: DetailsDialogProps) {
|
||||
const useChannel = other?.admin_info?.use_channel
|
||||
const channelChain =
|
||||
useChannel && useChannel.length > 0 ? useChannel.join(' → ') : undefined
|
||||
let reasoningEffortVariant: StatusBadgeProps['variant'] = 'green'
|
||||
if (other?.reasoning_effort === 'high') {
|
||||
reasoningEffortVariant = 'orange'
|
||||
} else if (other?.reasoning_effort === 'medium') {
|
||||
reasoningEffortVariant = 'yellow'
|
||||
}
|
||||
const reasoningEffortVariant = getReasoningEffortVariant(
|
||||
other?.reasoning_effort
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
|
||||
@@ -167,6 +167,25 @@ export function parseLogOther(other: string): LogOtherData | null {
|
||||
}
|
||||
}
|
||||
|
||||
export function getReasoningEffortVariant(
|
||||
effort: string | undefined
|
||||
): StatusBadgeProps['variant'] {
|
||||
switch (effort?.trim().toLowerCase()) {
|
||||
case 'max':
|
||||
case 'xhigh':
|
||||
case 'high':
|
||||
return 'orange'
|
||||
case 'medium':
|
||||
return 'yellow'
|
||||
case 'low':
|
||||
case 'minimal':
|
||||
return 'green'
|
||||
case 'none':
|
||||
default:
|
||||
return 'grey'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get time color based on duration (in seconds)
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user