diff --git a/dto/claude.go b/dto/claude.go index d7fed412aaa9..c75d45cf95a8 100644 --- a/dto/claude.go +++ b/dto/claude.go @@ -421,6 +421,18 @@ func (c *ClaudeRequest) GetEfforts() string { return "" } +func MergeEffortIntoOutputConfig(existing json.RawMessage, effort string) json.RawMessage { + oc := map[string]any{} + if len(existing) > 0 { + if err := common.Unmarshal(existing, &oc); err != nil { + common.SysLog("MergeEffortIntoOutputConfig: existing output_config is not a JSON object, ignoring: " + err.Error()) + } + } + oc["effort"] = effort + b, _ := common.Marshal(oc) + return b +} + // ProcessTools 处理工具列表,支持类型断言 func ProcessTools(tools []any) ([]*Tool, []*ClaudeWebSearchTool) { var normalTools []*Tool diff --git a/dto/claude_output_config_test.go b/dto/claude_output_config_test.go new file mode 100644 index 000000000000..2e860f703473 --- /dev/null +++ b/dto/claude_output_config_test.go @@ -0,0 +1,47 @@ +package dto + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestMergeEffortIntoOutputConfig_NilExisting(t *testing.T) { + result := MergeEffortIntoOutputConfig(nil, "xhigh") + var m map[string]any + require.NoError(t, json.Unmarshal(result, &m)) + require.Equal(t, "xhigh", m["effort"]) + require.Len(t, m, 1) +} + +func TestMergeEffortIntoOutputConfig_PreservesTaskBudget(t *testing.T) { + existing := json.RawMessage(`{"task_budget":{"type":"tokens","total":50000}}`) + result := MergeEffortIntoOutputConfig(existing, "high") + var m map[string]any + require.NoError(t, json.Unmarshal(result, &m)) + require.Equal(t, "high", m["effort"]) + tb, ok := m["task_budget"].(map[string]any) + require.True(t, ok, "task_budget should be preserved") + require.Equal(t, "tokens", tb["type"]) + require.Equal(t, float64(50000), tb["total"]) +} + +func TestMergeEffortIntoOutputConfig_OverridesExistingEffort(t *testing.T) { + existing := json.RawMessage(`{"effort":"low","task_budget":{"type":"tokens","total":20000}}`) + result := MergeEffortIntoOutputConfig(existing, "xhigh") + var m map[string]any + require.NoError(t, json.Unmarshal(result, &m)) + require.Equal(t, "xhigh", m["effort"]) + _, ok := m["task_budget"] + require.True(t, ok, "task_budget should be preserved after effort override") +} + +func TestMergeEffortIntoOutputConfig_EmptyJSON(t *testing.T) { + existing := json.RawMessage(`{}`) + result := MergeEffortIntoOutputConfig(existing, "medium") + var m map[string]any + require.NoError(t, json.Unmarshal(result, &m)) + require.Equal(t, "medium", m["effort"]) + require.Len(t, m, 1) +} diff --git a/dto/openai_request.go b/dto/openai_request.go index 25ef3a21aa51..53dac5860f4e 100644 --- a/dto/openai_request.go +++ b/dto/openai_request.go @@ -83,6 +83,7 @@ type GeneralOpenAIRequest struct { //xai SearchParameters json.RawMessage `json:"search_parameters,omitempty"` // claude + OutputConfig json.RawMessage `json:"output_config,omitempty"` WebSearchOptions *WebSearchOptions `json:"web_search_options,omitempty"` // OpenRouter Params Usage json.RawMessage `json:"usage,omitempty"` diff --git a/relay/channel/claude/adaptor.go b/relay/channel/claude/adaptor.go index 6daf5b6f245e..525ed7a9cb48 100644 --- a/relay/channel/claude/adaptor.go +++ b/relay/channel/claude/adaptor.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "net/url" + "strings" "github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/relay/channel" @@ -79,6 +80,21 @@ func CommonClaudeHeadersOperation(c *gin.Context, req *http.Header, info *relayc model_setting.GetClaudeSettings().WriteHeaders(info.OriginModelName, req) } +func EnsureBetaHeader(c *gin.Context, beta string) { + if c == nil { + return + } + existing := c.Request.Header.Get("anthropic-beta") + if strings.Contains(existing, beta) { + return + } + if existing != "" { + c.Request.Header.Set("anthropic-beta", existing+","+beta) + } else { + c.Request.Header.Set("anthropic-beta", beta) + } +} + func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { channel.SetupApiRequestHeader(info, c, req) req.Set("x-api-key", info.ApiKey) diff --git a/relay/channel/claude/relay-claude.go b/relay/channel/claude/relay-claude.go index fa8234523c77..a8005802bc8d 100644 --- a/relay/channel/claude/relay-claude.go +++ b/relay/channel/claude/relay-claude.go @@ -1,6 +1,7 @@ package claude import ( + "bytes" "encoding/json" "fmt" "io" @@ -153,13 +154,17 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe claudeRequest.MaxTokens = &defaultMaxTokens } + if len(textRequest.OutputConfig) > 0 { + claudeRequest.OutputConfig = textRequest.OutputConfig + } + if baseModel, effortLevel, ok := reasoning.TrimEffortSuffix(textRequest.Model); ok && effortLevel != "" && (strings.HasPrefix(textRequest.Model, "claude-opus-4-6") || strings.HasPrefix(textRequest.Model, "claude-opus-4-7")) { claudeRequest.Model = baseModel claudeRequest.Thinking = &dto.Thinking{ Type: "adaptive", } - claudeRequest.OutputConfig = json.RawMessage(fmt.Sprintf(`{"effort":"%s"}`, effortLevel)) + claudeRequest.OutputConfig = dto.MergeEffortIntoOutputConfig(claudeRequest.OutputConfig, effortLevel) if strings.HasPrefix(baseModel, "claude-opus-4-7") { // Opus 4.7 rejects non-default temperature/top_p/top_k with 400 // and defaults display to "omitted"; restore the 4.6 visible summary. @@ -178,7 +183,7 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe if strings.HasPrefix(trimmedModel, "claude-opus-4-7") { // Opus 4.7 rejects thinking.type="enabled"; use adaptive at high effort. claudeRequest.Thinking = &dto.Thinking{Type: "adaptive", Display: "summarized"} - claudeRequest.OutputConfig = json.RawMessage(`{"effort":"high"}`) + claudeRequest.OutputConfig = dto.MergeEffortIntoOutputConfig(claudeRequest.OutputConfig, "high") claudeRequest.Temperature = nil claudeRequest.TopP = nil claudeRequest.TopK = nil @@ -431,6 +436,11 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe claudeRequest.Prompt = "" claudeRequest.Messages = claudeMessages + + if bytes.Contains(claudeRequest.OutputConfig, []byte(`"task_budget"`)) { + EnsureBetaHeader(c, "task-budgets-2026-03-13") + } + return &claudeRequest, nil } diff --git a/relay/channel/claude/relay_claude_output_config_test.go b/relay/channel/claude/relay_claude_output_config_test.go new file mode 100644 index 000000000000..f9e2903cc182 --- /dev/null +++ b/relay/channel/claude/relay_claude_output_config_test.go @@ -0,0 +1,121 @@ +package claude + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/QuantumNous/new-api/dto" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestOutputConfig_BareModelPassthrough(t *testing.T) { + req := dto.GeneralOpenAIRequest{ + Model: "claude-opus-4-7", + Messages: []dto.Message{{Role: "user", Content: "hi"}}, + OutputConfig: json.RawMessage(`{"task_budget":{"type":"tokens","total":50000}}`), + } + cr, err := RequestOpenAI2ClaudeMessage(nil, req) + require.NoError(t, err) + var m map[string]any + require.NoError(t, json.Unmarshal(cr.OutputConfig, &m)) + tb, ok := m["task_budget"].(map[string]any) + require.True(t, ok, "task_budget should pass through on bare model") + require.Equal(t, "tokens", tb["type"]) + require.Equal(t, float64(50000), tb["total"]) +} + +func TestOutputConfig_SuffixMergesEffort(t *testing.T) { + req := dto.GeneralOpenAIRequest{ + Model: "claude-opus-4-7-xhigh", + Messages: []dto.Message{{Role: "user", Content: "hi"}}, + OutputConfig: json.RawMessage(`{"task_budget":{"type":"tokens","total":50000}}`), + } + cr, err := RequestOpenAI2ClaudeMessage(nil, req) + require.NoError(t, err) + require.Equal(t, "claude-opus-4-7", cr.Model, "suffix should be stripped") + var m map[string]any + require.NoError(t, json.Unmarshal(cr.OutputConfig, &m)) + require.Equal(t, "xhigh", m["effort"], "effort should be set from suffix") + tb, ok := m["task_budget"].(map[string]any) + require.True(t, ok, "task_budget should survive suffix merge") + require.Equal(t, float64(50000), tb["total"]) +} + +func TestOutputConfig_SuffixNoOutputConfig(t *testing.T) { + req := dto.GeneralOpenAIRequest{ + Model: "claude-opus-4-6-high", + Messages: []dto.Message{{Role: "user", Content: "hi"}}, + } + cr, err := RequestOpenAI2ClaudeMessage(nil, req) + require.NoError(t, err) + var m map[string]any + require.NoError(t, json.Unmarshal(cr.OutputConfig, &m)) + require.Equal(t, "high", m["effort"]) + require.Len(t, m, 1, "only effort should be present when no user output_config") +} + +func TestOutputConfig_ThinkingSuffix47Merge(t *testing.T) { + req := dto.GeneralOpenAIRequest{ + Model: "claude-opus-4-7-thinking", + Messages: []dto.Message{{Role: "user", Content: "hi"}}, + OutputConfig: json.RawMessage(`{"task_budget":{"type":"tokens","total":30000}}`), + } + cr, err := RequestOpenAI2ClaudeMessage(nil, req) + require.NoError(t, err) + var m map[string]any + require.NoError(t, json.Unmarshal(cr.OutputConfig, &m)) + require.Equal(t, "high", m["effort"], "thinking suffix should set effort=high for 4.7") + _, ok := m["task_budget"] + require.True(t, ok, "task_budget should survive thinking merge") +} + +func TestEnsureBetaHeader_InjectsWhenTaskBudgetPresent(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = &http.Request{Header: http.Header{}} + + req := dto.GeneralOpenAIRequest{ + Model: "claude-opus-4-7", + Messages: []dto.Message{{Role: "user", Content: "hi"}}, + OutputConfig: json.RawMessage(`{"task_budget":{"type":"tokens","total":50000}}`), + } + _, err := RequestOpenAI2ClaudeMessage(c, req) + require.NoError(t, err) + require.Contains(t, c.Request.Header.Get("anthropic-beta"), "task-budgets-2026-03-13") +} + +func TestEnsureBetaHeader_NoInjectWithoutTaskBudget(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = &http.Request{Header: http.Header{}} + + req := dto.GeneralOpenAIRequest{ + Model: "claude-opus-4-7", + Messages: []dto.Message{{Role: "user", Content: "hi"}}, + OutputConfig: json.RawMessage(`{"effort":"high"}`), + } + _, err := RequestOpenAI2ClaudeMessage(c, req) + require.NoError(t, err) + require.Empty(t, c.Request.Header.Get("anthropic-beta")) +} + +func TestEnsureBetaHeader_AppendsToExisting(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = &http.Request{Header: http.Header{}} + c.Request.Header.Set("anthropic-beta", "some-other-beta-2025-01-01") + + req := dto.GeneralOpenAIRequest{ + Model: "claude-opus-4-7", + Messages: []dto.Message{{Role: "user", Content: "hi"}}, + OutputConfig: json.RawMessage(`{"task_budget":{"type":"tokens","total":50000}}`), + } + _, err := RequestOpenAI2ClaudeMessage(c, req) + require.NoError(t, err) + beta := c.Request.Header.Get("anthropic-beta") + require.Contains(t, beta, "some-other-beta-2025-01-01") + require.Contains(t, beta, "task-budgets-2026-03-13") +} diff --git a/relay/claude_handler.go b/relay/claude_handler.go index 54f8ced2adf4..b6a25fb90696 100644 --- a/relay/claude_handler.go +++ b/relay/claude_handler.go @@ -2,7 +2,6 @@ package relay import ( "bytes" - "encoding/json" "fmt" "io" "net/http" @@ -58,7 +57,7 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ request.Thinking = &dto.Thinking{ Type: "adaptive", } - request.OutputConfig = json.RawMessage(fmt.Sprintf(`{"effort":"%s"}`, effortLevel)) + request.OutputConfig = dto.MergeEffortIntoOutputConfig(request.OutputConfig, effortLevel) if strings.HasPrefix(request.Model, "claude-opus-4-7") { // Opus 4.7 rejects non-default temperature/top_p/top_k with 400 // and defaults display to "omitted"; restore the 4.6 visible summary. @@ -77,7 +76,7 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ if strings.HasPrefix(baseModel, "claude-opus-4-7") { // Opus 4.7 rejects thinking.type="enabled"; use adaptive at high effort. request.Thinking = &dto.Thinking{Type: "adaptive", Display: "summarized"} - request.OutputConfig = json.RawMessage(`{"effort":"high"}`) + request.OutputConfig = dto.MergeEffortIntoOutputConfig(request.OutputConfig, "high") request.Temperature = nil request.TopP = nil request.TopK = nil