fix: record reasoning effort consistently in usage logs (#6641)

This commit is contained in:
Seefs
2026-08-10 12:48:14 +08:00
committed by GitHub
parent 7dd1000a19
commit eab18a8357
10 changed files with 333 additions and 14 deletions
+3 -3
View File
@@ -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)
}
}
+2 -2
View File
@@ -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
}
+1 -1
View File
@@ -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
+5
View File
@@ -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 {
+51
View File
@@ -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
+111
View File
@@ -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)
}
+39 -2
View File
@@ -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 {
+98
View File
@@ -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
+19
View File
@@ -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)
*/