From eab18a83579187f880139894dd9e7f06d1a492ce Mon Sep 17 00:00:00 2001 From: Seefs <40468931+seefs001@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:48:14 +0800 Subject: [PATCH] fix: record reasoning effort consistently in usage logs (#6641) --- relay/channel/deepseek/adaptor.go | 6 +- relay/channel/openai/adaptor.go | 4 +- relay/channel/xai/adaptor.go | 2 +- relay/claude_handler.go | 5 + relay/common/override.go | 51 ++++++++ relay/common/override_test.go | 111 ++++++++++++++++++ relay/common/relay_info.go | 41 ++++++- relay/common/relay_info_test.go | 98 ++++++++++++++++ .../components/dialogs/details-dialog.tsx | 10 +- web/src/features/usage-logs/lib/format.ts | 19 +++ 10 files changed, 333 insertions(+), 14 deletions(-) diff --git a/relay/channel/deepseek/adaptor.go b/relay/channel/deepseek/adaptor.go index ff13efa4..72805c8c 100644 --- a/relay/channel/deepseek/adaptor.go +++ b/relay/channel/deepseek/adaptor.go @@ -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) } } diff --git a/relay/channel/openai/adaptor.go b/relay/channel/openai/adaptor.go index 4f1c4286..64ae3102 100644 --- a/relay/channel/openai/adaptor.go +++ b/relay/channel/openai/adaptor.go @@ -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 } diff --git a/relay/channel/xai/adaptor.go b/relay/channel/xai/adaptor.go index 0db042f0..62b41b33 100644 --- a/relay/channel/xai/adaptor.go +++ b/relay/channel/xai/adaptor.go @@ -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 diff --git a/relay/claude_handler.go b/relay/claude_handler.go index c8b01f7a..ff785446 100644 --- a/relay/claude_handler.go +++ b/relay/claude_handler.go @@ -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 { diff --git a/relay/common/override.go b/relay/common/override.go index f92daf77..558fd55a 100644 --- a/relay/common/override.go +++ b/relay/common/override.go @@ -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 diff --git a/relay/common/override_test.go b/relay/common/override_test.go index c5fc7f5d..e1f84eaf 100644 --- a/relay/common/override_test.go +++ b/relay/common/override_test.go @@ -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) +} diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index 594e2640..b0bb19bd 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -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 { diff --git a/relay/common/relay_info_test.go b/relay/common/relay_info_test.go index 9e58f3f9..42a0f856 100644 --- a/relay/common/relay_info_test.go +++ b/relay/common/relay_info_test.go @@ -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) +} diff --git a/web/src/features/usage-logs/components/dialogs/details-dialog.tsx b/web/src/features/usage-logs/components/dialogs/details-dialog.tsx index 8f57ac6b..0c8478f4 100644 --- a/web/src/features/usage-logs/components/dialogs/details-dialog.tsx +++ b/web/src/features/usage-logs/components/dialogs/details-dialog.tsx @@ -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 (