Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions dto/claude.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 47 additions & 0 deletions dto/claude_output_config_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
1 change: 1 addition & 0 deletions dto/openai_request.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
16 changes: 16 additions & 0 deletions relay/channel/claude/adaptor.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"io"
"net/http"
"net/url"
"strings"

"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/relay/channel"
Expand Down Expand Up @@ -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)
Expand Down
14 changes: 12 additions & 2 deletions relay/channel/claude/relay-claude.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package claude

import (
"bytes"
"encoding/json"
"fmt"
"io"
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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
}

Expand Down
121 changes: 121 additions & 0 deletions relay/channel/claude/relay_claude_output_config_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
5 changes: 2 additions & 3 deletions relay/claude_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package relay

import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down