diff --git a/common/endpoint_defaults.go b/common/endpoint_defaults.go index 80ed7c80d4..8641784ee7 100644 --- a/common/endpoint_defaults.go +++ b/common/endpoint_defaults.go @@ -21,6 +21,8 @@ var defaultEndpointInfoMap = map[constant.EndpointType]EndpointInfo{ constant.EndpointTypeOpenAIResponse: {Path: "/v1/responses", Method: "POST"}, constant.EndpointTypeOpenAIResponseCompact: {Path: "/v1/responses/compact", Method: "POST"}, constant.EndpointTypeOpenAIAlphaSearch: {Path: "/v1/alpha/search", Method: "POST"}, + constant.EndpointTypeDeepSeekToolsSearch: {Path: "/v1/tools/search", Method: "POST"}, + constant.EndpointTypeDeepSeekToolsFetch: {Path: "/v1/tools/fetch", Method: "POST"}, constant.EndpointTypeAnthropic: {Path: "/v1/messages", Method: "POST"}, constant.EndpointTypeGemini: {Path: "/v1beta/models/{model}:generateContent", Method: "POST"}, constant.EndpointTypeJinaRerank: {Path: "/v1/rerank", Method: "POST"}, diff --git a/common/endpoint_type.go b/common/endpoint_type.go index 1e5a33cdfb..bc6d2a207e 100644 --- a/common/endpoint_type.go +++ b/common/endpoint_type.go @@ -4,6 +4,13 @@ import "github.com/QuantumNous/new-api/constant" // GetEndpointTypesByChannelType 获取渠道最优先端点类型(所有的渠道都支持 OpenAI 端点) func GetEndpointTypesByChannelType(channelType int, modelName string) []constant.EndpointType { + // The DeepSeek Search and Fetch routing names serve only their endpoint. + switch modelName { + case constant.DeepSeekSearchModel: + return []constant.EndpointType{constant.EndpointTypeDeepSeekToolsSearch} + case constant.DeepSeekFetchModel: + return []constant.EndpointType{constant.EndpointTypeDeepSeekToolsFetch} + } var endpointTypes []constant.EndpointType switch channelType { case constant.ChannelTypeJina: diff --git a/common/model_test.go b/common/model_test.go index 60538e9a30..e67583e613 100644 --- a/common/model_test.go +++ b/common/model_test.go @@ -26,3 +26,13 @@ func TestWanEndpointsDistinguishImagesFromVideos(t *testing.T) { }) } } + +// /v1/models reports the DeepSeek Search and Fetch routing names with their +// own endpoint on the channels that relay them; chat models keep their list. +func TestDeepSeekToolRoutingNamesReportTheirEndpoint(t *testing.T) { + for _, channelType := range []int{constant.ChannelTypeDeepSeek, constant.ChannelTypeNewAPI} { + assert.Equal(t, []constant.EndpointType{constant.EndpointTypeDeepSeekToolsSearch}, common.GetEndpointTypesByChannelType(channelType, constant.DeepSeekSearchModel)) + assert.Equal(t, []constant.EndpointType{constant.EndpointTypeDeepSeekToolsFetch}, common.GetEndpointTypesByChannelType(channelType, constant.DeepSeekFetchModel)) + } + assert.Equal(t, []constant.EndpointType{constant.EndpointTypeOpenAI}, common.GetEndpointTypesByChannelType(constant.ChannelTypeDeepSeek, "deepseek-flash")) +} diff --git a/constant/endpoint_type.go b/constant/endpoint_type.go index 50eed0d1f4..8efe2ac77d 100644 --- a/constant/endpoint_type.go +++ b/constant/endpoint_type.go @@ -11,6 +11,8 @@ const ( EndpointTypeOpenAIResponse = types.EndpointTypeOpenAIResponse EndpointTypeOpenAIResponseCompact = types.EndpointTypeOpenAIResponseCompact EndpointTypeOpenAIAlphaSearch = types.EndpointTypeOpenAIAlphaSearch + EndpointTypeDeepSeekToolsSearch = types.EndpointTypeDeepSeekToolsSearch + EndpointTypeDeepSeekToolsFetch = types.EndpointTypeDeepSeekToolsFetch EndpointTypeAnthropic = types.EndpointTypeAnthropic EndpointTypeGemini = types.EndpointTypeGemini EndpointTypeJinaRerank = types.EndpointTypeJinaRerank @@ -18,3 +20,10 @@ const ( EndpointTypeEmbeddings = types.EndpointTypeEmbeddings EndpointTypeOpenAIVideo = types.EndpointTypeOpenAIVideo ) + +// Model names the distributor assigns to /v1/tools/search and /v1/tools/fetch, +// whose bodies carry no model. Channels serve the endpoints by listing them. +const ( + DeepSeekSearchModel = "deepseek-search" + DeepSeekFetchModel = "deepseek-fetch" +) diff --git a/controller/relay.go b/controller/relay.go index 837c065ed1..250583a38f 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -50,6 +50,8 @@ func relayHandler(c *gin.Context, info *relaycommon.RelayInfo) *types.NewAPIErro err = relay.ResponsesHelper(c, info) case relayconstant.RelayModeAlphaSearch: err = relay.AlphaSearchHelper(c, info) + case relayconstant.RelayModeToolsSearch, relayconstant.RelayModeToolsFetch: + err = relay.DeepSeekToolsHelper(c, info) default: err = relay.TextHelper(c, info) } diff --git a/middleware/distributor.go b/middleware/distributor.go index 374a3b7f3f..88c4cb2818 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -458,6 +458,13 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) { modelRequest.Model = c.Param("model") } } + // The routing name is also the billing name, so a model named in the body + // must not replace it: DeepSeek ignores that field. + if strings.HasPrefix(c.Request.URL.Path, "/v1/tools/search") { + modelRequest.Model = constant.DeepSeekSearchModel + } else if strings.HasPrefix(c.Request.URL.Path, "/v1/tools/fetch") { + modelRequest.Model = constant.DeepSeekFetchModel + } if strings.HasPrefix(c.Request.URL.Path, "/v1/images/generations") { modelRequest.Model = common.GetStringIfEmpty(modelRequest.Model, "dall-e") } else if strings.HasPrefix(c.Request.URL.Path, "/v1/images/edits") { diff --git a/middleware/distributor_test.go b/middleware/distributor_test.go index 02c487f9d6..f1817bad0e 100644 --- a/middleware/distributor_test.go +++ b/middleware/distributor_test.go @@ -168,6 +168,27 @@ func TestTokenModelLimitAllowsLegacyAliasAndModifierVariant(t *testing.T) { assert.True(t, TokenModelLimitAllows(wildcard, "gemini-2.5-flash-thinking-8192")) } +// /v1/tools/search and /v1/tools/fetch always route and bill by the DeepSeek +// routing names. A model named in the body is ignored: a chat model there +// would price the call at zero tokens. +func TestGetModelRequestAssignsDeepSeekToolRoutingNames(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, tc := range []struct{ path, body, want string }{ + {"/v1/tools/search", `{"query":"deepseek","count":3}`, constant.DeepSeekSearchModel}, + {"/v1/tools/fetch", `{"url":"https://example.com/"}`, constant.DeepSeekFetchModel}, + {"/v1/tools/search", `{"query":"deepseek","model":"deepseek-flash"}`, constant.DeepSeekSearchModel}, + {"/v1/tools/fetch", `{"url":"https://example.com/","model":"deepseek-v4-pro"}`, constant.DeepSeekFetchModel}, + } { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, tc.path, strings.NewReader(tc.body)) + c.Request.Header.Set("Content-Type", "application/json") + modelRequest, shouldSelectChannel, err := getModelRequest(c) + require.NoError(t, err, tc.body) + assert.True(t, shouldSelectChannel, tc.body) + assert.Equal(t, tc.want, modelRequest.Model, tc.body) + } +} + func TestTokenModelLimitAllowsExemptAtNameByFullName(t *testing.T) { settings := model_setting.GetGlobalSettings() original := append([]string(nil), settings.ThinkingModelBlacklist...) diff --git a/model/ability.go b/model/ability.go index c3d4d12762..be41187235 100644 --- a/model/ability.go +++ b/model/ability.go @@ -8,6 +8,7 @@ import ( "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/setting/ratio_setting" "github.com/samber/lo" "gorm.io/gorm" @@ -116,6 +117,15 @@ func GetChannel( return nil, err } abilities = filterAbilitiesByConstraints(abilities, model, filters) + // Like the memory cache, retry with the routing name (for example without + // @ modifiers) when no channel serves the exact name. + if normalized := ratio_setting.RoutingMatchModelName(model); len(abilities) == 0 && normalized != "" && normalized != model { + err = DB.Where(commonGroupCol+" = ? and model = ? and enabled = ?", group, normalized, true).Order("priority DESC, weight DESC").Find(&abilities).Error + if err != nil { + return nil, err + } + abilities = filterAbilitiesByConstraints(abilities, model, filters) + } if len(abilities) > 0 { priorities := make([]int64, 0) seen := make(map[int64]bool) diff --git a/model/task_plugin_channel_select_test.go b/model/task_plugin_channel_select_test.go index 1b923fd8c0..8daa050faa 100644 --- a/model/task_plugin_channel_select_test.go +++ b/model/task_plugin_channel_select_test.go @@ -117,3 +117,23 @@ func TestNewAPIChannelServesExtendedTaskPlugins(t *testing.T) { require.NotNil(t, selected) assert.Equal(t, "gateway", selected.Name, "requests without a pinned plugin keep using the gateway") } + +func TestModelModifierNamesRouteWithAndWithoutMemoryCache(t *testing.T) { + truncateTables(t) + originalMemoryCache := common.MemoryCacheEnabled + t.Cleanup(func() { common.MemoryCacheEnabled = originalMemoryCache; InitChannelCache() }) + channel := Channel{Id: 930001, Type: constant.ChannelTypeDeepSeek, Status: common.ChannelStatusEnabled, Name: "deepseek", Models: "deepseek-flash", Group: "default"} + require.NoError(t, channel.Insert()) + + for _, memoryCache := range []bool{false, true} { + common.MemoryCacheEnabled = memoryCache + InitChannelCache() + selected, err := GetRandomSatisfiedChannel("default", "deepseek-flash@effort:max", 0, nil) + require.NoError(t, err) + require.NotNil(t, selected, "memory cache %v", memoryCache) + assert.Equal(t, "deepseek", selected.Name) + selected, err = GetRandomSatisfiedChannel("default", "deepseek-pro@effort:max", 0, nil) + require.NoError(t, err) + assert.Nil(t, selected, "memory cache %v", memoryCache) + } +} diff --git a/relay/alpha_search_handler_test.go b/relay/alpha_search_handler_test.go index 5847bad06f..412b009740 100644 --- a/relay/alpha_search_handler_test.go +++ b/relay/alpha_search_handler_test.go @@ -1,9 +1,20 @@ package relay import ( + "io" + "net/http" + "net/http/httptest" + "strings" "testing" "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/pkg/billingexpr" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/relay/helper" + "github.com/QuantumNous/new-api/relaykit/types" + "github.com/QuantumNous/new-api/service" + "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -45,3 +56,109 @@ func TestBuildAlphaSearchRequestBodyNoMappingKeepsRawBytes(t *testing.T) { require.NoError(t, err) assert.Equal(t, raw, out) } + +type recordedSettlement struct { + settled []int + refunded bool +} + +func (s *recordedSettlement) Settle(quota int) error { + s.settled = append(s.settled, quota) + return nil +} +func (s *recordedSettlement) Refund(*gin.Context) { s.refunded = true } +func (*recordedSettlement) NeedsRefund() bool { return false } +func (*recordedSettlement) GetPreConsumedQuota() int { return 0 } +func (*recordedSettlement) Reserve(int) error { return nil } + +// /v1/tools/search and /v1/tools/fetch follow /v1/alpha/search: the raw body +// reaches /v1/tools/* unchanged and the response returns unchanged. With only +// the built-in prices, a response DeepSeek bills (results, page content) +// settles one call at the routing name's request price and no tokens, an +// empty one releases the reservation, an upstream error keeps its status, and +// a channel that cannot relay the endpoint fails retryably without sending +// anything. +func TestDeepSeekToolsHelper(t *testing.T) { + service.InitHttpClient() + gin.SetMode(gin.TestMode) + batch, logConsume := common.BatchUpdateEnabled, common.LogConsumeEnabled + common.BatchUpdateEnabled, common.LogConsumeEnabled = true, false + t.Cleanup(func() { common.BatchUpdateEnabled, common.LogConsumeEnabled = batch, logConsume }) + + const search, fetch = "/v1/tools/search", "/v1/tools/fetch" + const results = `{"results":[{"title":"DeepSeek","url":"https://deepseek.com/","rerank_score":2.1}]}` + tests := []struct { + name, path, body, response string + channelType, status int + settled []int + refunded bool + }{ + {name: "search with results", path: search, body: `{"query":"deepseek","count":2,"future_field":{"nested":true}}`, + response: results, channelType: constant.ChannelTypeDeepSeek, status: http.StatusOK, settled: []int{375}}, + {name: "search without results", path: search, body: `{"query":"site:none.invalid deepseek"}`, + response: `{"results":[]}`, channelType: constant.ChannelTypeDeepSeek, status: http.StatusOK, refunded: true}, + {name: "fetch with page content", path: fetch, body: `{"url":"https://example.com/"}`, + response: `{"title":"Example Domain","content":"# Example Domain"}`, channelType: constant.ChannelTypeDeepSeek, status: http.StatusOK, settled: []int{375}}, + {name: "fetch failure keeps its status", path: fetch, body: `{"url":"https://example.com/missing"}`, + response: `{"error":{"message":"failed to fetch URL","type":"not_found_error","param":null,"code":"invalid_request_error"}}`, + channelType: constant.ChannelTypeDeepSeek, status: http.StatusNotFound}, + {name: "search through a New API channel", path: search, body: `{"query":"deepseek"}`, + response: results, channelType: constant.ChannelTypeNewAPI, status: http.StatusOK, settled: []int{375}}, + {name: "channel without the endpoint", path: search, body: `{"query":"deepseek"}`, channelType: constant.ChannelTypeOpenAI}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var gotPath string + var gotBody []byte + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotBody, _ = io.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tc.status) + _, _ = io.WriteString(w, tc.response) + })) + t.Cleanup(upstream.Close) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, tc.path, strings.NewReader(tc.body)) + c.Request.Header.Set("Content-Type", "application/json") + routingName := map[string]string{search: constant.DeepSeekSearchModel, fetch: constant.DeepSeekFetchModel}[tc.path] + common.SetContextKey(c, constant.ContextKeyOriginalModel, routingName) + common.SetContextKey(c, constant.ContextKeyUsingGroup, "default") + common.SetContextKey(c, constant.ContextKeyUserGroup, "default") + common.SetContextKey(c, constant.ContextKeyChannelType, tc.channelType) + common.SetContextKey(c, constant.ContextKeyChannelBaseUrl, upstream.URL) + common.SetContextKey(c, constant.ContextKeyChannelKey, "sk-test") + + request, err := helper.GetAndValidateRequest(c, types.RelayFormatDeepSeekTools) + require.NoError(t, err) + info, err := relaycommon.GenRelayInfo(c, types.RelayFormatDeepSeekTools, request, nil) + require.NoError(t, err) + info.BillingRequestInput = &billingexpr.RequestInput{Body: []byte(tc.body)} + _, err = helper.ModelPriceHelper(c, info, 0, request.GetTokenCountMeta()) + require.NoError(t, err, "the routing name is priced out of the box") + settlement := &recordedSettlement{} + info.Billing = settlement + + apiErr := DeepSeekToolsHelper(c, info) + if tc.status == 0 { + require.NotNil(t, apiErr) + assert.False(t, types.IsSkipRetryError(apiErr)) + assert.Empty(t, gotPath) + return + } + assert.Equal(t, tc.path, gotPath) + assert.Equal(t, tc.body, string(gotBody)) + assert.Equal(t, tc.settled, settlement.settled) + assert.Equal(t, tc.refunded, settlement.refunded) + if tc.status != http.StatusOK { + require.NotNil(t, apiErr) + assert.Equal(t, tc.status, apiErr.StatusCode) + return + } + require.Nil(t, apiErr) + assert.Equal(t, tc.status, recorder.Code) + assert.Equal(t, tc.response, recorder.Body.String()) + }) + } +} diff --git a/relay/channel/claude/relay-claude.go b/relay/channel/claude/relay-claude.go index 0f49bc260f..e720ef03db 100644 --- a/relay/channel/claude/relay-claude.go +++ b/relay/channel/claude/relay-claude.go @@ -226,10 +226,8 @@ func countClaudeStreamBillableTools(info *relaycommon.RelayInfo, claudeResponse if claudeResponse == nil { return } - if claudeResponse.Type == "content_block_start" && - claudeResponse.ContentBlock != nil && - claudeResponse.ContentBlock.Type == "tool_use" { - info.CountBillableToolCall(dto.BuildInCallToolUse, claudeResponse.ContentBlock.Name) + if claudeResponse.Type == "content_block_start" && claudeResponse.ContentBlock != nil { + countClaudeContentBlockTools(info, claudeResponse.ContentBlock) } if claudeResponse.Type == "message_delta" && claudeResponse.Usage != nil { // message_delta usage is cumulative, so the last report wins. @@ -237,16 +235,34 @@ func countClaudeStreamBillableTools(info *relaycommon.RelayInfo, claudeResponse } } +// countClaudeContentBlockTools counts the billable tool calls of one content +// block. On a channel whose upstream prices search itself +// (WebSearchBillingKey), every web_search_tool_result that carries results +// bills one search: usage.server_tool_use replaces that count when reported, +// and it stands when a stream ends before message_delta. +func countClaudeContentBlockTools(info *relaycommon.RelayInfo, block *dto.ClaudeMediaMessage) { + switch block.Type { + case "tool_use": + info.CountBillableToolCall(dto.BuildInCallToolUse, block.Name) + case "web_search_tool_result": + if _, delivered := block.Content.([]any); delivered && info.WebSearchBillingKey != "" { + info.CountBillableToolCall(dto.BuildInCallWebSearchCall, "") + } + } +} + // recordClaudeServerToolUse bills the server tool requests Anthropic reports -// in usage.server_tool_use. web_fetch, code_execution, and tool_search have no -// built-in price and bill only once an operator prices them. +// in usage.server_tool_use. Searches and fetches use the channel's own keys +// when its adaptor sets them; otherwise web_fetch, code_execution, and +// tool_search have no built-in price and bill only once an operator prices +// them. func recordClaudeServerToolUse(info *relaycommon.RelayInfo, serverToolUse *dto.ClaudeServerToolUse) { if info == nil || serverToolUse == nil { return } for name, count := range map[string]int{ cmp.Or(info.WebSearchBillingKey, dto.BuildInToolWebSearch): serverToolUse.WebSearchRequests, - "web_fetch": serverToolUse.WebFetchRequests, + cmp.Or(info.WebFetchBillingKey, "web_fetch"): serverToolUse.WebFetchRequests, "code_execution": serverToolUse.CodeExecutionRequests, "tool_search": serverToolUse.ToolSearchRequests, } { @@ -411,16 +427,13 @@ func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud } } + for i := range claudeResponse.Content { + countClaudeContentBlockTools(info, &claudeResponse.Content[i]) + } if claudeResponse.Usage != nil { recordClaudeServerToolUse(info, claudeResponse.Usage.ServerToolUse) } - for _, block := range claudeResponse.Content { - if block.Type == "tool_use" { - info.CountBillableToolCall(dto.BuildInCallToolUse, block.Name) - } - } - service.IOCopyBytesGracefully(c, httpResp, responseData) return nil } diff --git a/relay/channel/claude/tool_billing_test.go b/relay/channel/claude/tool_billing_test.go index 39b6ee54fe..49a2ffe656 100644 --- a/relay/channel/claude/tool_billing_test.go +++ b/relay/channel/claude/tool_billing_test.go @@ -1,9 +1,12 @@ package claude import ( + "cmp" + "fmt" "net/http/httptest" "testing" + "github.com/QuantumNous/new-api/common" relaycommon "github.com/QuantumNous/new-api/relay/common" "github.com/QuantumNous/new-api/relaykit/dto" "github.com/QuantumNous/new-api/relaykit/types" @@ -81,6 +84,7 @@ func TestCountClaudeStreamBillableToolsServerToolKeys(t *testing.T) { tests := []struct { name string billingKey string + fetchKey string usage dto.ClaudeServerToolUse want map[string]int }{ @@ -90,6 +94,13 @@ func TestCountClaudeStreamBillableToolsServerToolKeys(t *testing.T) { usage: dto.ClaudeServerToolUse{WebSearchRequests: 2}, want: map[string]int{"search_strategy_agent": 2}, }, + { + name: "vendor web search and web fetch keys", + billingKey: "deepseek_search", + fetchKey: "deepseek_fetch", + usage: dto.ClaudeServerToolUse{WebSearchRequests: 1, WebFetchRequests: 2}, + want: map[string]int{"deepseek_search": 1, "deepseek_fetch": 2}, + }, { name: "every server tool", usage: dto.ClaudeServerToolUse{WebSearchRequests: 1, WebFetchRequests: 2, CodeExecutionRequests: 3, ToolSearchRequests: 4}, @@ -98,7 +109,7 @@ func TestCountClaudeStreamBillableToolsServerToolKeys(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - info := &relaycommon.RelayInfo{OriginModelName: "claude-3-7-sonnet", WebSearchBillingKey: tt.billingKey} + info := &relaycommon.RelayInfo{OriginModelName: "claude-3-7-sonnet", WebSearchBillingKey: tt.billingKey, WebFetchBillingKey: tt.fetchKey} countClaudeStreamBillableTools(info, &dto.ClaudeResponse{ Type: "message_delta", Usage: &dto.ClaudeUsage{ServerToolUse: &tt.usage}, @@ -111,3 +122,56 @@ func TestCountClaudeStreamBillableToolsServerToolKeys(t *testing.T) { }) } } + +// On a channel whose upstream prices search itself (DeepSeek), each delivered +// web_search_tool_result bills one search under the vendor key, so a stream cut +// before message_delta still pays for the searches that ran; +// usage.server_tool_use replaces that count when reported. Error results and +// channels without a vendor key count nothing from blocks. +func TestClaudeSearchResultBlocksBillVendorSearch(t *testing.T) { + gin.SetMode(gin.TestMode) + const results = `{"type":"web_search_tool_result","tool_use_id":"call_0","content":[{"type":"web_search_result","url":"https://example.com/"}]}` + const empty = `{"type":"web_search_tool_result","tool_use_id":"call_1","content":[]}` + const failed = `{"type":"web_search_tool_result","tool_use_id":"call_2","content":{"type":"web_search_tool_result_error","error_code":"unavailable"}}` + tests := []struct { + name, billingKey, blocks, usage string + want map[string]int + }{ + {name: "stream cut before message_delta", billingKey: "deepseek_search", blocks: results + "," + empty + "," + failed, + want: map[string]int{"deepseek_search": 2}}, + {name: "reported count replaces blocks", billingKey: "deepseek_search", blocks: results + "," + results, + usage: `{"input_tokens":1,"output_tokens":1,"server_tool_use":{"web_search_requests":3}}`, want: map[string]int{"deepseek_search": 3}}, + {name: "no vendor key", blocks: results, want: map[string]int{}}, + } + for _, tt := range tests { + for _, stream := range []bool{true, false} { + t.Run(fmt.Sprintf("%s stream=%t", tt.name, stream), func(t *testing.T) { + info := &relaycommon.RelayInfo{OriginModelName: "claude-sonnet-4-5", RelayFormat: types.RelayFormatClaude, WebSearchBillingKey: tt.billingKey} + var blocks []dto.ClaudeMediaMessage + require.NoError(t, common.UnmarshalJsonStr("["+tt.blocks+"]", &blocks)) + if stream { + for i := range blocks { + countClaudeStreamBillableTools(info, &dto.ClaudeResponse{Type: "content_block_start", ContentBlock: &blocks[i]}) + } + if tt.usage != "" { + var usage dto.ClaudeUsage + require.NoError(t, common.UnmarshalJsonStr(tt.usage, &usage)) + countClaudeStreamBillableTools(info, &dto.ClaudeResponse{Type: "message_delta", Usage: &usage}) + } + } else { + usage := cmp.Or(tt.usage, `{"input_tokens":1,"output_tokens":1}`) + data := []byte(`{"type":"message","content":[` + tt.blocks + `],"usage":` + usage + `}`) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + require.Nil(t, HandleClaudeResponseData(c, info, &ClaudeResponseInfo{Usage: &dto.Usage{}}, nil, data)) + } + counts := map[string]int{} + if info.ResponsesUsageInfo != nil { + for name, tool := range info.ResponsesUsageInfo.BuiltInTools { + counts[name] = tool.CallCount + } + } + assert.Equal(t, tt.want, counts) + }) + } + } +} diff --git a/relay/channel/deepseek/adaptor.go b/relay/channel/deepseek/adaptor.go index ea8cbb7617..4d3f987efe 100644 --- a/relay/channel/deepseek/adaptor.go +++ b/relay/channel/deepseek/adaptor.go @@ -14,6 +14,7 @@ import ( relaycommon "github.com/QuantumNous/new-api/relay/common" "github.com/QuantumNous/new-api/relay/constant" "github.com/QuantumNous/new-api/relaykit/dto" + kitreasoning "github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning" "github.com/QuantumNous/new-api/relaykit/types" "github.com/QuantumNous/new-api/setting/model_setting" "github.com/QuantumNous/new-api/setting/reasoning" @@ -30,7 +31,13 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) { adaptor := claude.Adaptor{} + // A model modifier is rendered into DeepSeek's own thinking and effort + // fields below; Anthropic's model rules (budgets, adaptive thinking, + // max_tokens and sampling changes) must not see it. + state := info.ReasoningConversion + info.ReasoningConversion = nil convertedRequest, err := adaptor.ConvertClaudeRequest(c, info, req) + info.ReasoningConversion = state if err != nil { return nil, err } @@ -55,6 +62,14 @@ func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInf } func (a *Adaptor) Init(info *relaycommon.RelayInfo) { + // DeepSeek runs Claude Code and Codex searches and page fetches + // server-side and bills them through its Search and Fetch APIs, whatever + // model name the client sent. + info.WebSearchBillingKey = SearchToolPriceKey + info.WebFetchBillingKey = FetchToolPriceKey + if info.RelayMode == constant.RelayModeResponses { + info.VendorToolUsage = responsesToolUsage + } } func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { @@ -71,6 +86,10 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { return fmt.Sprintf("%s/completions", fimBaseUrl), nil case constant.RelayModeResponses: return fmt.Sprintf("%s/responses", info.ChannelBaseUrl), nil + case constant.RelayModeToolsSearch: + return fmt.Sprintf("%s/v1/tools/search", info.ChannelBaseUrl), nil + case constant.RelayModeToolsFetch: + return fmt.Sprintf("%s/v1/tools/fetch", info.ChannelBaseUrl), nil default: return fmt.Sprintf("%s/v1/chat/completions", info.ChannelBaseUrl), nil } @@ -99,10 +118,7 @@ func applyDeepSeekV4OpenAIThinkingSuffix(info *relaycommon.RelayInfo, request *d if info != nil && info.ChannelMeta != nil && info.UpstreamModelName != "" { modelName = info.UpstreamModelName } - if model_setting.ShouldPreserveThinkingSuffix(modelName) || info != nil && model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) { - return nil - } - baseModel, thinkingType, effort, ok := reasoning.ParseDeepSeekV4ThinkingSuffix(modelName) + baseModel, thinkingType, effort, ok := deepSeekThinking(info, modelName) if !ok { return nil } @@ -115,11 +131,15 @@ func applyDeepSeekV4OpenAIThinkingSuffix(info *relaycommon.RelayInfo, request *d request.Model = baseModel request.THINKING = thinking request.ReasoningEffort = effort + if info.ReasoningState() != nil { + // DeepSeek Chat has no reasoning object; a budget modifier adds one. + request.Reasoning = nil + } if info != nil { if info.ChannelMeta != nil { info.UpstreamModelName = baseModel } - info.SetReasoningEffort(effort) + info.SetReasoningEffort(deepSeekRunEffort(thinkingType, effort)) } return nil } @@ -129,31 +149,36 @@ func applyDeepSeekV4ClaudeThinkingSuffix(info *relaycommon.RelayInfo, request *d if info != nil && info.ChannelMeta != nil && info.UpstreamModelName != "" { modelName = info.UpstreamModelName } - if model_setting.ShouldPreserveThinkingSuffix(modelName) || info != nil && model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) { - return nil - } - baseModel, thinkingType, effort, ok := reasoning.ParseDeepSeekV4ThinkingSuffix(modelName) + baseModel, thinkingType, effort, ok := deepSeekThinking(info, modelName) if !ok { return nil } request.Model = baseModel request.Thinking = &dto.Thinking{Type: thinkingType} - if effort == "" { - request.OutputConfig = nil - } else { - outputConfig, err := common.Marshal(map[string]string{ - "effort": effort, - }) + // Only effort is set or removed; other output_config members stay. + outputConfig := make(map[string]any) + if len(request.OutputConfig) > 0 && common.GetJsonType(request.OutputConfig) == "object" { + if err := common.Unmarshal(request.OutputConfig, &outputConfig); err != nil { + return fmt.Errorf("invalid output_config: %w", err) + } + } + delete(outputConfig, "effort") + if effort != "" { + outputConfig["effort"] = effort + } + request.OutputConfig = nil + if len(outputConfig) > 0 { + encoded, err := common.Marshal(outputConfig) if err != nil { return fmt.Errorf("error marshalling output_config: %w", err) } - request.OutputConfig = outputConfig + request.OutputConfig = encoded } if info != nil { if info.ChannelMeta != nil { info.UpstreamModelName = baseModel } - info.SetReasoningEffort(effort) + info.SetReasoningEffort(deepSeekRunEffort(thinkingType, effort)) } return nil } @@ -177,25 +202,71 @@ func applyDeepSeekV4ResponsesThinkingSuffix(info *relaycommon.RelayInfo, request if info != nil && info.ChannelMeta != nil && info.UpstreamModelName != "" { modelName = info.UpstreamModelName } - if model_setting.ShouldPreserveThinkingSuffix(modelName) || info != nil && model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) { + baseModel, thinkingType, effort, ok := deepSeekThinking(info, modelName) + if !ok { + if info != nil && request.Reasoning != nil { + info.SetReasoningEffort(request.Reasoning.Effort) + } return } - baseModel, thinkingType, effort, ok := reasoning.ParseDeepSeekV4ThinkingSuffix(modelName) - if ok { - if thinkingType == "disabled" { - effort = "none" - } - request.Model = baseModel - if request.Reasoning == nil { - request.Reasoning = &dto.Reasoning{} - } + if thinkingType == "disabled" { + effort = "none" + } + request.Model = baseModel + if request.Reasoning == nil && effort != "" { + request.Reasoning = &dto.Reasoning{} + } + if request.Reasoning != nil { request.Reasoning.Effort = effort - if info != nil && info.ChannelMeta != nil { + } + if info != nil { + if info.ChannelMeta != nil { info.UpstreamModelName = baseModel } + info.SetReasoningEffort(deepSeekRunEffort(thinkingType, effort)) } - if info != nil && request.Reasoning != nil { - info.SetReasoningEffort(request.Reasoning.Effort) +} + +// deepSeekThinking returns the model name to send with DeepSeek's thinking +// switch and effort. An explicit @ modifier (the reasoning state) wins over a +// -none/-low/-max tail, which is still stripped from the name. A budget maps +// to an effort as on other effort-only targets; thinking on or adaptive leaves +// the effort to DeepSeek's default. +func deepSeekThinking(info *relaycommon.RelayInfo, modelName string) (baseModel string, thinkingType string, effort string, ok bool) { + baseModel = modelName + if !model_setting.ShouldPreserveThinkingSuffix(modelName) && (info == nil || !model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName)) { + baseModel, thinkingType, effort, ok = reasoning.ParseDeepSeekV4ThinkingSuffix(modelName) + } + state := info.ReasoningState() + if state == nil { + return baseModel, thinkingType, effort, ok + } + intent := kitreasoning.IntentFromState(state) + switch runs := kitreasoning.EffectiveEffort(intent); { + case runs == "": + return baseModel, thinkingType, effort, ok + case runs == kitreasoning.EffortNone: + return baseModel, "disabled", "", true + case intent.Effort == "" && intent.BudgetTokens == nil: + return baseModel, "enabled", "", true + default: + return baseModel, "enabled", string(runs), true + } +} + +// deepSeekRunEffort is the effort DeepSeek runs: none with thinking off, its +// default high when no effort is sent, and the levels it does not implement +// mapped as DeepSeek maps them (minimal to low, medium and xhigh to high). +func deepSeekRunEffort(thinkingType string, effort string) string { + switch { + case thinkingType == "disabled": + return string(kitreasoning.EffortNone) + case effort == "" || effort == "medium" || effort == "xhigh": + return "high" + case effort == "minimal": + return "low" + default: + return effort } } diff --git a/relay/channel/deepseek/adaptor_test.go b/relay/channel/deepseek/adaptor_test.go new file mode 100644 index 0000000000..7707c0ae29 --- /dev/null +++ b/relay/channel/deepseek/adaptor_test.go @@ -0,0 +1,270 @@ +package deepseek + +import ( + "fmt" + "strings" + "testing" + + "github.com/QuantumNous/new-api/common" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/relay/constant" + "github.com/QuantumNous/new-api/relay/helper" + "github.com/QuantumNous/new-api/relaykit/dto" + "github.com/QuantumNous/new-api/relaykit/types" + "github.com/QuantumNous/new-api/setting/model_setting" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// The -none/-low/-max tails of deepseek-flash and deepseek-v4-* reach DeepSeek +// as its own thinking switch and effort in each upstream format, with the base +// name; a bare name keeps DeepSeek's default, and a blacklisted name is sent +// verbatim. +func TestThinkingSuffixUpstreamFields(t *testing.T) { + settings := model_setting.GetGlobalSettings() + original := append([]string(nil), settings.ThinkingModelBlacklist...) + t.Cleanup(func() { settings.ThinkingModelBlacklist = original }) + settings.ThinkingModelBlacklist = append(original, "deepseek-v4-pro-max") + + tests := []struct { + model, upstream, thinking, effort string + }{ + {model: "deepseek-flash", upstream: "deepseek-flash"}, + {model: "deepseek-flash-none", upstream: "deepseek-flash", thinking: "disabled"}, + {model: "deepseek-flash-low", upstream: "deepseek-flash", thinking: "enabled", effort: "low"}, + {model: "deepseek-flash-max", upstream: "deepseek-flash", thinking: "enabled", effort: "max"}, + {model: "deepseek-v4-pro-none", upstream: "deepseek-v4-pro", thinking: "disabled"}, + {model: "deepseek-v4-pro-low", upstream: "deepseek-v4-pro", thinking: "enabled", effort: "low"}, + {model: "deepseek-v4-pro-max", upstream: "deepseek-v4-pro-max"}, + } + newInfo := func(model string) *relaycommon.RelayInfo { + return &relaycommon.RelayInfo{OriginModelName: model, ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: model}} + } + for _, tt := range tests { + t.Run(tt.model, func(t *testing.T) { + chat := &dto.GeneralOpenAIRequest{Model: tt.model} + converted, err := (&Adaptor{}).ConvertOpenAIRequest(nil, newInfo(tt.model), chat) + require.NoError(t, err) + chat = converted.(*dto.GeneralOpenAIRequest) + assert.Equal(t, tt.upstream, chat.Model) + assert.Equal(t, tt.effort, chat.ReasoningEffort) + + claude := &dto.ClaudeRequest{Model: tt.model} + require.NoError(t, applyDeepSeekV4ClaudeThinkingSuffix(newInfo(tt.model), claude)) + assert.Equal(t, tt.upstream, claude.Model) + + converted, err = (&Adaptor{}).ConvertOpenAIResponsesRequest(nil, newInfo(tt.model), dto.OpenAIResponsesRequest{Model: tt.model}) + require.NoError(t, err) + responses := converted.(dto.OpenAIResponsesRequest) + assert.Equal(t, tt.upstream, responses.Model) + + if tt.thinking == "" { + assert.Empty(t, chat.THINKING) + assert.Nil(t, claude.Thinking) + assert.Nil(t, claude.OutputConfig) + assert.Nil(t, responses.Reasoning) + return + } + assert.JSONEq(t, `{"type":"`+tt.thinking+`"}`, string(chat.THINKING)) + require.NotNil(t, claude.Thinking) + assert.Equal(t, tt.thinking, claude.Thinking.Type) + require.NotNil(t, responses.Reasoning) + if tt.thinking == "disabled" { + assert.Nil(t, claude.OutputConfig) + assert.Equal(t, "none", responses.Reasoning.Effort) + return + } + assert.JSONEq(t, `{"effort":"`+tt.effort+`"}`, string(claude.OutputConfig)) + assert.Equal(t, tt.effort, responses.Reasoning.Effort) + }) + } +} + +// DeepSeek runs Claude Code and Codex searches server-side and bills them as +// Search and Fetch API calls, whatever model name the client sent. On the +// Responses API the completed web_search_call items of the whole response +// replace the items counted while streaming: open_page is a Fetch call, a +// failed call is free, and an empty output keeps the streamed count. +func TestServerSideSearchBillingKeys(t *testing.T) { + for _, tc := range []struct { + format types.RelayFormat + mode int + }{ + {types.RelayFormatClaude, constant.RelayModeUnknown}, + {types.RelayFormatOpenAI, constant.RelayModeChatCompletions}, + {types.RelayFormatOpenAIResponses, constant.RelayModeResponses}, + } { + info := &relaycommon.RelayInfo{RelayFormat: tc.format, RelayMode: tc.mode, ChannelMeta: &relaycommon.ChannelMeta{}} + (&Adaptor{}).Init(info) + assert.Equal(t, SearchToolPriceKey, info.WebSearchBillingKey, tc.format) + assert.Equal(t, FetchToolPriceKey, info.WebFetchBillingKey, tc.format) + assert.Equal(t, tc.mode == constant.RelayModeResponses, info.VendorToolUsage != nil, tc.format) + } + + const output = `[ + {"type":"web_search_call","status":"completed","action":{"type":"search","queries":["deepseek","ws_call_id=call_01"]}}, + {"type":"web_search_call","status":"completed","action":{"type":"open_page","url":"https://example.com/"}}, + {"type":"web_search_call","status":"failed","action":{"type":"open_page","url":"https://api-docs.deepseek.com/"}}, + {"type":"message","status":"completed"}]` + for _, raw := range []string{ + `{"status":"completed","output":` + output + `}`, + `{"type":"response.completed","response":{"status":"completed","output":` + output + `}}`, + } { + info := &relaycommon.RelayInfo{ + OriginModelName: "gpt-5.1-codex", + RelayFormat: types.RelayFormatOpenAIResponses, + RelayMode: constant.RelayModeResponses, + ChannelMeta: &relaycommon.ChannelMeta{}, + } + (&Adaptor{}).Init(info) + for range 3 { + info.CountBillableToolCall(dto.BuildInCallWebSearchCall, "") + } + assert.Equal(t, map[string]int{SearchToolPriceKey: 3}, toolCounts(info), "streamed items bill under the Search key") + + info.ApplyVendorToolUsage([]byte(`{"type":"response.failed","response":{"status":"failed","output":[]}}`)) + assert.Equal(t, map[string]int{SearchToolPriceKey: 3}, toolCounts(info), "an empty output keeps the streamed count") + + info.ApplyVendorToolUsage([]byte(raw)) + assert.Equal(t, map[string]int{SearchToolPriceKey: 1, FetchToolPriceKey: 1}, toolCounts(info), raw) + } +} + +func toolCounts(info *relaycommon.RelayInfo) map[string]int { + counts := map[string]int{} + for name, tool := range info.ResponsesUsageInfo.BuiltInTools { + counts[name] = tool.CallCount + } + return counts +} + +// Explicit @ modifiers reach DeepSeek as its own thinking switch and effort in +// every format and override the client's reasoning fields: no budget_tokens, +// adaptive thinking, max_tokens raise or sampling change. An @ effort wins +// over a -none/-low/-max tail, a budget maps to an effort, and a request +// without a modifier passes through unchanged. The usage log records the +// effort DeepSeek runs at. +func TestModelModifiersUpstreamFields(t *testing.T) { + type client struct{ chat, claude, responses string } + tests := []struct { + name, model string + client client + thinking, effort string // thinking "" = untouched + logEffort string + }{ + {name: "thinking on", model: "deepseek-flash@thinking:on", thinking: "enabled", logEffort: "high"}, + {name: "thinking off", model: "deepseek-flash@thinking:off", thinking: "disabled", logEffort: "none"}, + {name: "thinking zero budget", model: "deepseek-flash@thinking:0", thinking: "disabled", logEffort: "none"}, + {name: "effort none", model: "deepseek-flash@effort:none", thinking: "disabled", logEffort: "none"}, + {name: "effort low", model: "deepseek-flash@effort:low", thinking: "enabled", effort: "low", logEffort: "low"}, + {name: "effort medium", model: "deepseek-flash@effort:medium", thinking: "enabled", effort: "medium", logEffort: "high"}, + {name: "effort xhigh", model: "deepseek-flash@effort:xhigh", thinking: "enabled", effort: "xhigh", logEffort: "high"}, + {name: "effort max on pro", model: "deepseek-v4-pro@effort:max", thinking: "enabled", effort: "max", logEffort: "max"}, + {name: "budget maps to an effort", model: "deepseek-flash@thinking:8192", thinking: "enabled", effort: "medium", logEffort: "high"}, + {name: "adaptive", model: "deepseek-flash@thinking:adaptive", thinking: "enabled", logEffort: "high"}, + {name: "temperature with effort", model: "deepseek-flash@temperature:0.3@effort:low", thinking: "enabled", effort: "low", logEffort: "low"}, + {name: "effort overrides client thinking off", model: "deepseek-flash@effort:max", thinking: "enabled", effort: "max", logEffort: "max", + client: client{chat: `"thinking":{"type":"disabled"}`, claude: `"thinking":{"type":"disabled"}`, responses: `"reasoning":{"effort":"none"}`}}, + {name: "thinking off overrides client thinking", model: "deepseek-flash@thinking:off", thinking: "disabled", logEffort: "none", + client: client{chat: `"thinking":{"type":"enabled"},"reasoning_effort":"high"`, + claude: `"thinking":{"type":"enabled","budget_tokens":2048},"output_config":{"effort":"high","format":{"type":"json_schema"}}`, + responses: `"reasoning":{"effort":"high"}`}}, + {name: "budget overrides client effort none", model: "deepseek-flash@thinking:8192", thinking: "enabled", effort: "medium", logEffort: "high", + client: client{responses: `"reasoning":{"effort":"none"}`}}, + {name: "@ effort wins over a tail", model: "deepseek-flash-max@effort:low", thinking: "enabled", effort: "low", logEffort: "low"}, + {name: "no modifier", model: "deepseek-flash", + client: client{chat: `"thinking":{"type":"disabled"},"reasoning_effort":"low"`, claude: `"thinking":{"type":"adaptive"},"output_config":{"effort":"low"}`, responses: `"reasoning":{"effort":"low"}`}}, + } + extra := func(members string) string { + if members == "" { + return "" + } + return "," + members + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + bodies := map[types.RelayFormat]string{ + types.RelayFormatOpenAI: fmt.Sprintf(`{"model":%q,"messages":[{"role":"user","content":"hi"}]%s}`, tt.model, extra(tt.client.chat)), + types.RelayFormatClaude: fmt.Sprintf(`{"model":%q,"max_tokens":64,"temperature":0.7,"messages":[{"role":"user","content":"hi"}]%s}`, tt.model, extra(tt.client.claude)), + types.RelayFormatOpenAIResponses: fmt.Sprintf(`{"model":%q,"input":"hi"%s}`, tt.model, extra(tt.client.responses)), + } + for format, body := range bodies { + info := &relaycommon.RelayInfo{ + OriginModelName: tt.model, + RelayFormat: format, + ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: tt.model}, + } + var sent []byte + switch format { + case types.RelayFormatOpenAI: + original, outbound := &dto.GeneralOpenAIRequest{}, &dto.GeneralOpenAIRequest{} + require.NoError(t, common.UnmarshalJsonStr(body, original)) + require.NoError(t, common.UnmarshalJsonStr(body, outbound)) + info.Request = original + require.NoError(t, helper.ApplyReasoningModelSuffix(nil, info, outbound)) + converted, err := (&Adaptor{}).ConvertOpenAIRequest(nil, info, outbound) + require.NoError(t, err) + sent, err = common.Marshal(converted) + require.NoError(t, err) + case types.RelayFormatClaude: + original, outbound := &dto.ClaudeRequest{}, &dto.ClaudeRequest{} + require.NoError(t, common.UnmarshalJsonStr(body, original)) + require.NoError(t, common.UnmarshalJsonStr(body, outbound)) + info.Request = original + require.NoError(t, helper.ApplyReasoningModelSuffix(nil, info, outbound)) + converted, err := (&Adaptor{}).ConvertClaudeRequest(nil, info, outbound) + require.NoError(t, err) + sent, err = common.Marshal(converted) + require.NoError(t, err) + default: + original, outbound := &dto.OpenAIResponsesRequest{}, &dto.OpenAIResponsesRequest{} + require.NoError(t, common.UnmarshalJsonStr(body, original)) + require.NoError(t, common.UnmarshalJsonStr(body, outbound)) + info.Request = original + require.NoError(t, helper.ApplyReasoningModelSuffix(nil, info, outbound)) + converted, err := (&Adaptor{}).ConvertOpenAIResponsesRequest(nil, info, *outbound) + require.NoError(t, err) + sent, err = common.Marshal(converted) + require.NoError(t, err) + } + wire := gjson.ParseBytes(sent) + wantModel := "deepseek-flash" + if strings.HasPrefix(tt.model, "deepseek-v4-pro") { + wantModel = "deepseek-v4-pro" + } + assert.Equal(t, wantModel, wire.Get("model").String(), format) + if tt.thinking == "" { + // No modifier: the client's reasoning fields go out as sent. + for _, member := range []string{"thinking", "reasoning_effort", "output_config", "reasoning"} { + assert.Equal(t, gjson.Get(body, member).Raw, wire.Get(member).Raw, "%s %s", format, member) + } + continue + } + switch format { + case types.RelayFormatOpenAI: + assert.JSONEq(t, `{"type":"`+tt.thinking+`"}`, wire.Get("thinking").Raw, format) + assert.Equal(t, tt.effort, wire.Get("reasoning_effort").String(), format) + assert.False(t, wire.Get("reasoning").Exists(), format) + case types.RelayFormatClaude: + assert.JSONEq(t, `{"type":"`+tt.thinking+`"}`, wire.Get("thinking").Raw, format) + assert.Equal(t, tt.effort, wire.Get("output_config.effort").String(), format) + assert.Equal(t, gjson.Get(body, "output_config.format").Raw, wire.Get("output_config.format").Raw, format) + assert.Equal(t, int64(64), wire.Get("max_tokens").Int(), format) + wantTemperature := 0.7 + if strings.Contains(tt.model, "@temperature:0.3") { + wantTemperature = 0.3 + } + assert.Equal(t, wantTemperature, wire.Get("temperature").Float(), format) + default: + wantEffort := tt.effort + if tt.thinking == "disabled" { + wantEffort = "none" + } + assert.Equal(t, wantEffort, wire.Get("reasoning.effort").String(), format) + } + assert.Equal(t, tt.logEffort, info.ReasoningEffort, format) + } + }) + } +} diff --git a/relay/channel/deepseek/constants.go b/relay/channel/deepseek/constants.go index 83f013305e..c78db0f814 100644 --- a/relay/channel/deepseek/constants.go +++ b/relay/channel/deepseek/constants.go @@ -1,9 +1,19 @@ package deepseek +import "github.com/QuantumNous/new-api/constant" + var ModelList = []string{ - "deepseek-chat", "deepseek-reasoner", - "deepseek-v4-flash", "deepseek-v4-flash-none", "deepseek-v4-flash-max", - "deepseek-v4-pro", "deepseek-v4-pro-none", "deepseek-v4-pro-max", + "deepseek-flash", "deepseek-flash-none", "deepseek-flash-low", "deepseek-flash-max", + "deepseek-v4-pro", "deepseek-v4-pro-none", "deepseek-v4-pro-low", "deepseek-v4-pro-max", + constant.DeepSeekSearchModel, constant.DeepSeekFetchModel, } var ChannelName = "deepseek" + +// Tool-price keys of the DeepSeek Search and Fetch APIs. They price the +// server-side searches and page fetches DeepSeek runs on its Anthropic and +// Responses endpoints. +const ( + SearchToolPriceKey = "deepseek_search" + FetchToolPriceKey = "deepseek_fetch" +) diff --git a/relay/channel/deepseek/web_search.go b/relay/channel/deepseek/web_search.go new file mode 100644 index 0000000000..4796d4eb12 --- /dev/null +++ b/relay/channel/deepseek/web_search.go @@ -0,0 +1,37 @@ +package deepseek + +import ( + "github.com/QuantumNous/new-api/relaykit/dto" + + "github.com/tidwall/gjson" +) + +// responsesToolUsage bills the Search and Fetch API calls DeepSeek runs for +// the Responses web_search tool from the web_search_call items of the whole +// response (output on non-stream bodies, response.output on terminal stream +// frames); its usage reports tokens only. An open_page action is a Fetch call, +// any other action a Search call, and a failed call is free. The counts +// replace the web_search_call items counted under the Search key while the +// stream was running. An empty output proves nothing, so those counts stay. +func responsesToolUsage(raw []byte) map[string]int { + output := gjson.GetBytes(raw, "output") + if !output.Exists() { + output = gjson.GetBytes(raw, "response.output") + } + items := output.Array() + if !output.IsArray() || len(items) == 0 { + return nil + } + counts := map[string]int{SearchToolPriceKey: 0, FetchToolPriceKey: 0} + for _, item := range items { + if item.Get("type").String() != dto.BuildInCallWebSearchCall || item.Get("status").String() != "completed" { + continue + } + if item.Get("action.type").String() == "open_page" { + counts[FetchToolPriceKey]++ + } else { + counts[SearchToolPriceKey]++ + } + } + return counts +} diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index af7f516e4a..ddfbe4e850 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -199,11 +199,17 @@ type RelayInfo struct { // value suppresses the protocol items counted under that key, so a vendor // count can supersede web_search_call items billed under a different key. VendorToolUsage func(raw []byte) map[string]int - // WebSearchBillingKey overrides the tool-price key the Claude handler uses - // for usage.server_tool_use.web_search_requests. Set by a channel adaptor - // whose Anthropic-compatible endpoint bills search under its own price - // list; empty keeps the protocol default web_search. + // WebSearchBillingKey overrides the tool-price key of hosted web search: + // usage.server_tool_use.web_search_requests and web_search_tool_result + // blocks in the Claude handler, and web_search_call items counted by + // CountBillableToolCall. Set by a channel adaptor whose upstream bills + // search under its own price list; empty keeps the protocol defaults. WebSearchBillingKey string + // WebFetchBillingKey overrides the tool-price key of + // usage.server_tool_use.web_fetch_requests in the Claude handler, for an + // upstream that bills page fetches under its own price list; empty keeps + // the protocol default web_fetch. + WebFetchBillingKey string // requestInferredToolKey is the tool-price key RecordRequestInferredToolCall // billed for the current channel attempt. requestInferredToolKey string @@ -329,6 +335,7 @@ func (info *RelayInfo) InitChannelMeta(c *gin.Context) { // The adaptor Init of this attempt sets the vendor billing hooks again. info.VendorToolUsage = nil info.WebSearchBillingKey = "" + info.WebFetchBillingKey = "" if info.requestInferredToolKey != "" && info.ResponsesUsageInfo != nil { delete(info.ResponsesUsageInfo.BuiltInTools, info.requestInferredToolKey) } @@ -722,6 +729,9 @@ func GenRelayInfo(c *gin.Context, relayFormat types.RelayFormat, request dto.Req return GenRelayInfoAlphaSearch(c, request), nil } return nil, errors.New("request is not a AlphaSearchRequest") + case types.RelayFormatDeepSeekTools: + info = genBaseRelayInfo(c, request) + info.RelayFormat = types.RelayFormatDeepSeekTools case types.RelayFormatTask: info = genBaseRelayInfo(c, nil) info.TaskRelayInfo = &TaskRelayInfo{} diff --git a/relay/common/tool_usage.go b/relay/common/tool_usage.go index 3ad1ffcb9c..5fe0e92ecb 100644 --- a/relay/common/tool_usage.go +++ b/relay/common/tool_usage.go @@ -1,6 +1,7 @@ package common import ( + "cmp" "crypto/sha256" "encoding/hex" "fmt" @@ -41,7 +42,7 @@ func (info *RelayInfo) CountBillableToolCall(itemType string, functionName strin switch itemType { case dto.BuildInCallWebSearchCall: - info.incrementBillableToolCall(resolveWebSearchToolName(info.ResponsesUsageInfo.BuiltInTools)) + info.incrementBillableToolCall(cmp.Or(info.WebSearchBillingKey, resolveWebSearchToolName(info.ResponsesUsageInfo.BuiltInTools))) case dto.BuildInCallFileSearchCall: info.incrementBillableToolCall(dto.BuildInToolFileSearch) case dto.BuildInCallFunctionCall, dto.BuildInCallToolUse: diff --git a/relay/common/tool_usage_test.go b/relay/common/tool_usage_test.go index e7ae564d2c..b488f64527 100644 --- a/relay/common/tool_usage_test.go +++ b/relay/common/tool_usage_test.go @@ -37,6 +37,25 @@ func TestCountBillableToolCallWebSearchDefaultsToPreview(t *testing.T) { assert.Equal(t, 1, info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearchPreview].CallCount) } +// A channel whose upstream bills search itself keys web_search_call items to +// its own price, so a mapped claude-* or gpt-* name never bills web_search. +func TestCountBillableToolCallWebSearchUsesChannelBillingKey(t *testing.T) { + info := &RelayInfo{ + OriginModelName: "gpt-5.1-codex", + WebSearchBillingKey: "deepseek_search", + ResponsesUsageInfo: &ResponsesUsageInfo{ + BuiltInTools: map[string]*BuildInToolInfo{ + dto.BuildInToolWebSearch: {ToolName: dto.BuildInToolWebSearch, CallCount: 0}, + }, + }, + } + + info.CountBillableToolCall(dto.BuildInCallWebSearchCall, "") + require.Contains(t, info.ResponsesUsageInfo.BuiltInTools, "deepseek_search") + assert.Equal(t, 1, info.ResponsesUsageInfo.BuiltInTools["deepseek_search"].CallCount) + assert.Equal(t, 0, info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearch].CallCount) +} + func TestCountBillableToolCallFunctionCallRequiresPrice(t *testing.T) { operation_setting.SetToolPriceForTest("my_priced_fn", 5.0) t.Cleanup(func() { @@ -130,6 +149,7 @@ func TestBillableToolCountSources(t *testing.T) { info.RecordRequestInferredToolCall("search_strategy_turbo") info.VendorToolUsage = func([]byte) map[string]int { return nil } info.WebSearchBillingKey = "search_strategy_agent" + info.WebFetchBillingKey = "deepseek_fetch" info.InitChannelMeta(c) }, want: map[string]int{dto.BuildInToolWebSearch: 2}, @@ -148,6 +168,7 @@ func TestBillableToolCountSources(t *testing.T) { assert.Equal(t, tt.want, counts) assert.Equal(t, tt.wantVendorHook, info.VendorToolUsage != nil) assert.Equal(t, tt.wantBillingKey, info.WebSearchBillingKey) + assert.Empty(t, info.WebFetchBillingKey) }) } } diff --git a/relay/constant/relay_mode.go b/relay/constant/relay_mode.go index f191c3b66f..f72ed497a1 100644 --- a/relay/constant/relay_mode.go +++ b/relay/constant/relay_mode.go @@ -49,6 +49,9 @@ const ( RelayModeResponsesCompact RelayModeAlphaSearch + + RelayModeToolsSearch + RelayModeToolsFetch ) func Path2RelayMode(path string) int { @@ -75,6 +78,10 @@ func Path2RelayMode(path string) int { relayMode = RelayModeResponses } else if strings.HasPrefix(path, "/v1/alpha/search") { relayMode = RelayModeAlphaSearch + } else if strings.HasPrefix(path, "/v1/tools/search") { + relayMode = RelayModeToolsSearch + } else if strings.HasPrefix(path, "/v1/tools/fetch") { + relayMode = RelayModeToolsFetch } else if strings.HasPrefix(path, "/v1/audio/speech") { relayMode = RelayModeAudioSpeech } else if strings.HasPrefix(path, "/v1/audio/transcriptions") { diff --git a/relay/constant/relay_mode_test.go b/relay/constant/relay_mode_test.go index 6c419815c2..4c2ca7556a 100644 --- a/relay/constant/relay_mode_test.go +++ b/relay/constant/relay_mode_test.go @@ -13,6 +13,8 @@ func TestPath2RelayMode(t *testing.T) { }{ {path: "/v1/alpha/search", want: RelayModeAlphaSearch}, {path: "/v1/alpha/search?foo=1", want: RelayModeAlphaSearch}, + {path: "/v1/tools/search", want: RelayModeToolsSearch}, + {path: "/v1/tools/fetch", want: RelayModeToolsFetch}, } for _, tt := range tests { t.Run(tt.path, func(t *testing.T) { diff --git a/relay/deepseek_tools_handler.go b/relay/deepseek_tools_handler.go new file mode 100644 index 0000000000..37120b6007 --- /dev/null +++ b/relay/deepseek_tools_handler.go @@ -0,0 +1,127 @@ +package relay + +import ( + "errors" + "fmt" + "io" + "net/http" + + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/logger" + relaycommon "github.com/QuantumNous/new-api/relay/common" + relayconstant "github.com/QuantumNous/new-api/relay/constant" + "github.com/QuantumNous/new-api/relay/helper" + "github.com/QuantumNous/new-api/relaykit/dto" + "github.com/QuantumNous/new-api/relaykit/types" + "github.com/QuantumNous/new-api/service" + + "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" +) + +// DeepSeekToolsHelper relays the DeepSeek Search and Fetch APIs like +// /v1/alpha/search: the raw body is forwarded, the response is returned +// unchanged, and the call is billed at the routing name's request price with +// no tokens. +func DeepSeekToolsHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types.NewAPIError) { + info.InitChannelMeta(c) + + switch info.ChannelType { + case constant.ChannelTypeDeepSeek, + constant.ChannelTypeNewAPI, + constant.ChannelTypeSub2API, + constant.ChannelTypeAdvancedCustom: + default: + // Allow retry onto another channel that may support this endpoint. + return types.NewError( + fmt.Errorf("channel does not support %s", c.Request.URL.Path), + types.ErrorCodeInvalidRequest, + ) + } + + request, ok := info.Request.(*dto.DeepSeekToolsRequest) + if !ok { + return types.NewErrorWithStatusCode( + fmt.Errorf("invalid request type, expected *dto.DeepSeekToolsRequest, got %T", info.Request), + types.ErrorCodeInvalidRequest, + http.StatusBadRequest, + types.ErrOptionWithSkipRetry(), + ) + } + + err := helper.ModelMappedHelper(c, info, request) + if err != nil { + return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry()) + } + + jsonData, err := buildAlphaSearchRequestBody(request.RawBody, info.OriginModelName, info.UpstreamModelName) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + + if len(info.ParamOverride) > 0 { + jsonData, err = relaycommon.ApplyParamOverrideWithRelayInfo(jsonData, info) + if err != nil { + return newAPIErrorFromParamOverride(err) + } + } + + logger.LogDebug(c, "requestBody: %s", jsonData) + body, closer, err := relaycommon.NewOutboundJSONBody(jsonData) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + defer closer.Close() + + adaptor := GetAdaptor(info.ApiType) + if adaptor == nil { + return types.NewError(fmt.Errorf("invalid api type: %d", info.ApiType), types.ErrorCodeInvalidApiType, types.ErrOptionWithSkipRetry()) + } + adaptor.Init(info) + + resp, err := adaptor.DoRequest(c, info, body) + if err != nil { + return types.NewOpenAIError(err, types.ErrorCodeDoRequestFailed, http.StatusInternalServerError) + } + + statusCodeMappingStr := c.GetString("status_code_mapping") + httpResp, ok := resp.(*http.Response) + if !ok || httpResp == nil { + return types.NewOpenAIError(errors.New("invalid http response"), types.ErrorCodeDoRequestFailed, http.StatusInternalServerError) + } + defer httpResp.Body.Close() + + if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { + newAPIError = service.RelayErrorHandler(c.Request.Context(), httpResp, false) + service.ResetStatusCode(newAPIError, statusCodeMappingStr) + return newAPIError + } + + responseBody, err := io.ReadAll(httpResp.Body) + if err != nil { + return types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError, types.ErrOptionWithSkipRetry()) + } + service.IOCopyBytesGracefully(c, httpResp, responseBody) + + // DeepSeek bills a successful request only when it returns something: a + // non-empty results array for Search, page content for Fetch. A billable + // call settles at the routing name's request price; any other response + // releases the reservation. + var billable bool + switch info.RelayMode { + case relayconstant.RelayModeToolsFetch: + billable = gjson.GetBytes(responseBody, "content").String() != "" + default: + results := gjson.GetBytes(responseBody, "results") + billable = results.IsArray() && len(results.Array()) > 0 + } + if !billable { + if info.Billing != nil { + info.Billing.Refund(c) + } + return nil + } + + service.PostTextConsumeQuota(c, info, &dto.Usage{}, nil) + return nil +} diff --git a/relay/helper/price.go b/relay/helper/price.go index 7e78f277f5..3cd2963f17 100644 --- a/relay/helper/price.go +++ b/relay/helper/price.go @@ -15,6 +15,7 @@ import ( "github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning" "github.com/QuantumNous/new-api/relaykit/types" "github.com/QuantumNous/new-api/setting/billing_setting" + "github.com/QuantumNous/new-api/setting/model_setting" "github.com/QuantumNous/new-api/setting/operation_setting" "github.com/QuantumNous/new-api/setting/ratio_setting" hostreasoning "github.com/QuantumNous/new-api/setting/reasoning" @@ -311,6 +312,11 @@ func resolveBillingModelName(origin string) string { } candidates = append(candidates, hostreasoning.CanonicalBillingModelNames(origin)...) base := hostreasoning.BaseModelName(origin) + // DeepSeek thinking tails (-none/-low/-max) bill at the base model's price, + // with or without @ modifiers after them. + if deepSeekBase, _, _, ok := reasoning.ParseDeepSeekV4ThinkingSuffix(base); ok && !model_setting.ShouldPreserveThinkingSuffix(base) { + candidates = append(candidates, deepSeekBase) + } candidates = append(candidates, base) seen := make(map[string]struct{}, len(candidates)) diff --git a/relay/helper/price_test.go b/relay/helper/price_test.go index 4b8ca50ba3..6a1fefa0fe 100644 --- a/relay/helper/price_test.go +++ b/relay/helper/price_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/i18n" "github.com/QuantumNous/new-api/pkg/billingexpr" relaycommon "github.com/QuantumNous/new-api/relay/common" "github.com/QuantumNous/new-api/relaykit/dto" @@ -645,6 +646,60 @@ func TestModelPriceHelperPreservesGpt51CodexMaxIdentity(t *testing.T) { assert.Equal(t, 1.75, priceData.ModelRatio) } +// DeepSeek thinking tails and @ modifiers bill at the base model's built-in +// expression; a name on the thinking-suffix blacklist stays opaque and needs +// its own price. +func TestModelPriceHelperDeepSeekThinkingTailsBillAtBase(t *testing.T) { + gin.SetMode(gin.TestMode) + require.NoError(t, i18n.Init()) + saved := map[string]string{} + require.NoError(t, config.GlobalConfig.SaveToDB(func(key, value string) error { + saved[key] = value + return nil + })) + t.Cleanup(func() { require.NoError(t, config.GlobalConfig.LoadFromDB(saved)) }) + require.NoError(t, config.GlobalConfig.LoadFromDB(map[string]string{ + "billing_setting.billing_mode": `{}`, + "billing_setting.billing_expr": `{}`, + })) + oldSelfUse := operation_setting.SelfUseModeEnabled + operation_setting.SelfUseModeEnabled = false + t.Cleanup(func() { operation_setting.SelfUseModeEnabled = oldSelfUse }) + settings := model_setting.GetGlobalSettings() + originalBlacklist := append([]string(nil), settings.ThinkingModelBlacklist...) + t.Cleanup(func() { settings.ThinkingModelBlacklist = originalBlacklist }) + settings.ThinkingModelBlacklist = append(originalBlacklist, "deepseek-v4-pro-max") + + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + ctx.Set("group", "default") + for origin, base := range map[string]string{ + "deepseek-flash-none": "deepseek-flash", + "deepseek-flash-low": "deepseek-flash", + "deepseek-flash-max": "deepseek-flash", + "deepseek-v4-pro-low": "deepseek-v4-pro", + "deepseek-flash@effort:max": "deepseek-flash", + "deepseek-flash-max@effort:low": "deepseek-flash", + "deepseek-v4-pro@thinking:off": "deepseek-v4-pro", + } { + info := &relaycommon.RelayInfo{ + OriginModelName: origin, UserGroup: "default", UsingGroup: "default", + BillingRequestInput: &billingexpr.RequestInput{Body: []byte(`{}`)}, + } + _, err := ModelPriceHelper(ctx, info, 1000, &types.TokenCountMeta{}) + require.NoError(t, err, origin) + assert.Equal(t, base, info.BillingModelName, origin) + expression, _ := billing_setting.GetBuiltinBillingExpr(base) + require.NotNil(t, info.TieredBillingSnapshot, origin) + assert.Equal(t, expression, info.TieredBillingSnapshot.ExprString, origin) + } + + info := &relaycommon.RelayInfo{OriginModelName: "deepseek-v4-pro-max", UserGroup: "default", UsingGroup: "default"} + _, err := ModelPriceHelper(ctx, info, 1000, &types.TokenCountMeta{}) + require.Error(t, err) + assert.Equal(t, "deepseek-v4-pro-max", info.GetBillingModelName()) +} + func TestModelPriceHelperNativeGeminiNoThinkingDoesNotAliasBillingModel(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/relay/helper/valid_request.go b/relay/helper/valid_request.go index 7dad5a18d2..d5709757fe 100644 --- a/relay/helper/valid_request.go +++ b/relay/helper/valid_request.go @@ -41,6 +41,8 @@ func GetAndValidateRequest(c *gin.Context, format types.RelayFormat) (request dt request, err = GetAndValidateResponsesCompactionRequest(c) case types.RelayFormatOpenAIAlphaSearch: request, err = GetAndValidateAlphaSearchRequest(c) + case types.RelayFormatDeepSeekTools: + request, err = GetAndValidateDeepSeekToolsRequest(c) case types.RelayFormatOpenAIImage: request, err = GetAndValidOpenAIImageRequest(c, relayMode) @@ -170,6 +172,28 @@ func GetAndValidateAlphaSearchRequest(c *gin.Context) (*dto.AlphaSearchRequest, return request, nil } +// GetAndValidateDeepSeekToolsRequest keeps the raw body for forwarding. The +// upstream validates query/url itself, and no field changes the per-call price. +func GetAndValidateDeepSeekToolsRequest(c *gin.Context) (*dto.DeepSeekToolsRequest, error) { + request := &dto.DeepSeekToolsRequest{} + if err := common.UnmarshalBodyReusable(c, request); err != nil { + return nil, err + } + storage, err := common.GetBodyStorage(c) + if err != nil { + return nil, err + } + rawBody, err := storage.Bytes() + if err != nil { + return nil, err + } + if len(rawBody) == 0 { + return nil, errors.New("request body is empty") + } + request.RawBody = rawBody + return request, nil +} + func GetAndValidateResponsesCompactionRequest(c *gin.Context) (*dto.OpenAIResponsesCompactionRequest, error) { request := &dto.OpenAIResponsesCompactionRequest{} if err := common.UnmarshalBodyReusable(c, request); err != nil { diff --git a/relaykit/dto/channel_settings.go b/relaykit/dto/channel_settings.go index a4eb39f7a8..2f82e5baa1 100644 --- a/relaykit/dto/channel_settings.go +++ b/relaykit/dto/channel_settings.go @@ -223,6 +223,8 @@ const ( advancedCustomEndpointPathOpenAIResponses = "/v1/responses" advancedCustomEndpointPathOpenAIResponsesCompact = "/v1/responses/compact" advancedCustomEndpointPathOpenAIAlphaSearch = "/v1/alpha/search" + advancedCustomEndpointPathDeepSeekToolsSearch = "/v1/tools/search" + advancedCustomEndpointPathDeepSeekToolsFetch = "/v1/tools/fetch" advancedCustomEndpointPathClaudeMessages = "/v1/messages" advancedCustomEndpointPathJinaRerank = "/v1/rerank" advancedCustomEndpointPathImageGeneration = "/v1/images/generations" @@ -348,6 +350,10 @@ func advancedCustomEndpointTypeFromIncomingPath(incomingPath string) (types.Endp return types.EndpointTypeOpenAIResponseCompact, true case advancedCustomEndpointPathOpenAIAlphaSearch: return types.EndpointTypeOpenAIAlphaSearch, true + case advancedCustomEndpointPathDeepSeekToolsSearch: + return types.EndpointTypeDeepSeekToolsSearch, true + case advancedCustomEndpointPathDeepSeekToolsFetch: + return types.EndpointTypeDeepSeekToolsFetch, true case advancedCustomEndpointPathClaudeMessages: return types.EndpointTypeAnthropic, true case advancedCustomEndpointPathJinaRerank: diff --git a/relaykit/dto/deepseek_tools_request.go b/relaykit/dto/deepseek_tools_request.go new file mode 100644 index 0000000000..3a1a22e216 --- /dev/null +++ b/relaykit/dto/deepseek_tools_request.go @@ -0,0 +1,34 @@ +package dto + +import ( + "encoding/json" + "net/http" + + "github.com/QuantumNous/new-api/relaykit/types" +) + +// DeepSeekToolsRequest is a DeepSeek Search or Fetch API request +// (/v1/tools/search, /v1/tools/fetch). The body names no model; Model holds +// the routing name. RawBody preserves the original JSON so the body is +// forwarded with unknown fields intact. +type DeepSeekToolsRequest struct { + Model string `json:"model,omitempty"` + RawBody json.RawMessage `json:"-"` +} + +func (r *DeepSeekToolsRequest) GetTokenCountMeta() *types.TokenCountMeta { + return &types.TokenCountMeta{ + CombineText: string(r.RawBody), + TokenType: types.TokenTypeTokenizer, + } +} + +func (r *DeepSeekToolsRequest) IsStream(_ *http.Request) bool { + return false +} + +func (r *DeepSeekToolsRequest) SetModelName(modelName string) { + if modelName != "" { + r.Model = modelName + } +} diff --git a/relaykit/relayconvert/reasoning/suffix.go b/relaykit/relayconvert/reasoning/suffix.go index b60bc91261..0c246d258d 100644 --- a/relaykit/relayconvert/reasoning/suffix.go +++ b/relaykit/relayconvert/reasoning/suffix.go @@ -13,7 +13,7 @@ var EffortSuffixes = []string{"-max", "-xhigh", "-high", "-medium", "-low", "-mi var OpenAIEffortSuffixes = []string{"-max", "-xhigh", "-high", "-medium", "-low", "-minimal", "-none"} -var DeepSeekV4EffortSuffixes = []string{"-none", "-max"} +var DeepSeekV4EffortSuffixes = []string{"-none", "-low", "-max"} var ( legacyOpenAIModelPattern = regexp.MustCompile(`^(gpt-[a-z0-9][a-z0-9._-]*|o[1-9][a-z0-9._-]*)$`) @@ -274,16 +274,19 @@ func parseProviderModelSuffix(modelName string, requiredPrefix string, allowSuff return baseModel, intent, true, nil } +// ParseDeepSeekV4ThinkingSuffix maps the -none/-low/-max tails of +// deepseek-flash and deepseek-v4-* onto DeepSeek's thinking switch and +// effort. A bare name keeps DeepSeek's default (thinking on, effort high). func ParseDeepSeekV4ThinkingSuffix(modelName string) (baseModel string, thinkingType string, effort string, ok bool) { baseModel, suffix, ok := TrimEffortSuffixWithSuffixes(modelName, DeepSeekV4EffortSuffixes) - if !ok || !strings.HasPrefix(baseModel, "deepseek-v4-") { + if !ok || baseModel != "deepseek-flash" && !strings.HasPrefix(baseModel, "deepseek-v4-") { return modelName, "", "", false } switch suffix { case "none": return baseModel, "disabled", "", true - case "max": - return baseModel, "enabled", "max", true + case "low", "max": + return baseModel, "enabled", suffix, true default: return modelName, "", "", false } diff --git a/relaykit/types/endpoint_type.go b/relaykit/types/endpoint_type.go index f2f96fa93c..25909ff926 100644 --- a/relaykit/types/endpoint_type.go +++ b/relaykit/types/endpoint_type.go @@ -10,6 +10,8 @@ const ( EndpointTypeOpenAIResponse EndpointType = "openai-response" EndpointTypeOpenAIResponseCompact EndpointType = "openai-response-compact" EndpointTypeOpenAIAlphaSearch EndpointType = "openai-alpha-search" + EndpointTypeDeepSeekToolsSearch EndpointType = "deepseek-tools-search" + EndpointTypeDeepSeekToolsFetch EndpointType = "deepseek-tools-fetch" EndpointTypeAnthropic EndpointType = "anthropic" EndpointTypeGemini EndpointType = "gemini" EndpointTypeJinaRerank EndpointType = "jina-rerank" diff --git a/relaykit/types/relay_format.go b/relaykit/types/relay_format.go index 86c2de176e..a3bcb84302 100644 --- a/relaykit/types/relay_format.go +++ b/relaykit/types/relay_format.go @@ -9,6 +9,7 @@ const ( RelayFormatOpenAIResponses = "openai_responses" RelayFormatOpenAIResponsesCompaction = "openai_responses_compaction" RelayFormatOpenAIAlphaSearch = "openai_alpha_search" + RelayFormatDeepSeekTools = "deepseek_tools" RelayFormatOpenAIAudio = "openai_audio" RelayFormatOpenAIImage = "openai_image" RelayFormatOpenAIRealtime = "openai_realtime" diff --git a/router/relay-router.go b/router/relay-router.go index 90deaaa047..3f796c5d6e 100644 --- a/router/relay-router.go +++ b/router/relay-router.go @@ -116,6 +116,14 @@ func SetRelayRouter(router *gin.Engine) { controller.Relay(c, types.RelayFormatOpenAIAlphaSearch) }) + // DeepSeek Search and Fetch API routes + httpRouter.POST("/tools/search", func(c *gin.Context) { + controller.Relay(c, types.RelayFormatDeepSeekTools) + }) + httpRouter.POST("/tools/fetch", func(c *gin.Context) { + controller.Relay(c, types.RelayFormatDeepSeekTools) + }) + // image related routes. /images/generations and /images/edits are // host protocol endpoints (openai_image) registered by // SetTaskPluginProtocolRouter; unclaimed models fall back to Relay. diff --git a/setting/billing_setting/builtin_billing.go b/setting/billing_setting/builtin_billing.go index 2e0b1a3be5..b03370db8a 100644 --- a/setting/billing_setting/builtin_billing.go +++ b/setting/billing_setting/builtin_billing.go @@ -1,5 +1,14 @@ package billing_setting +import "github.com/QuantumNous/new-api/constant" + +// deepSeekPeak is DeepSeek's peak window: Monday to Friday, 09:00-12:00 and +// 14:00-18:00 Beijing time. The Chinese public holidays DeepSeek bills +// off-peak are not known in advance and bill at peak here. +const deepSeekPeak = `weekday("Asia/Shanghai") >= 1 && weekday("Asia/Shanghai") <= 5 && ((hour("Asia/Shanghai") >= 9 && hour("Asia/Shanghai") < 12) || (hour("Asia/Shanghai") >= 14 && hour("Asia/Shanghai") < 18))` + +const deepSeekFlashExpr = deepSeekPeak + ` ? tier("peak", p * 0.3 + cr * 0.006 + c * 1.2) : tier("off_peak", p * 0.15 + cr * 0.003 + c * 0.6)` + // Built-in token prices use actual USD per million tokens. Keep new model // defaults here instead of splitting them across the legacy ratio tables. var builtinBillingExpr = map[string]string{ @@ -13,4 +22,16 @@ var builtinBillingExpr = map[string]string{ // Do not infer service-tier discounts from incoming request parameters: // channels filter service_tier by default, so it may not reach the upstream. "gpt-6-astra": `len <= 272000 ? tier("standard", p * 10 + c * 50 + cr * 1 + cc * 12.5) : tier("long_context", p * 20 + c * 75 + cr * 2 + cc * 25)`, + // https://api-docs.deepseek.com/quick_start/pricing (2026-10-17). Off-peak + // is half the peak price. deepseek-v4-flash and deepseek-v4-flash-vision-exp + // run on V4.1 Flash and bill at the Flash price. Images bill as input + // tokens, and there is no cache-write price. + "deepseek-flash": deepSeekFlashExpr, + "deepseek-v4-flash": deepSeekFlashExpr, + "deepseek-v4-flash-vision-exp": deepSeekFlashExpr, + "deepseek-v4-pro": deepSeekPeak + ` ? tier("peak", p * 1.32 + cr * 0.044 + c * 3.96) : tier("off_peak", p * 0.66 + cr * 0.022 + c * 1.98)`, + // The Search and Fetch API routing names: $0.75 per 1,000 successful + // requests each, the same pricing page. The calls carry no tokens. + constant.DeepSeekSearchModel: `tier("request", fixed(0.00075))`, + constant.DeepSeekFetchModel: `tier("request", fixed(0.00075))`, } diff --git a/setting/billing_setting/builtin_billing_test.go b/setting/billing_setting/builtin_billing_test.go index 64ede29cab..e940d6bd9f 100644 --- a/setting/billing_setting/builtin_billing_test.go +++ b/setting/billing_setting/builtin_billing_test.go @@ -148,3 +148,61 @@ func TestImageModelBuiltinPricesAndOverrides(t *testing.T) { }) } } + +// DeepSeek prices are peak/off-peak expressions in USD per 1M tokens; peak is +// twice off-peak. The same usage settles to the same quota from a Chat, +// Responses or Anthropic response, and the Search/Fetch routing names cost +// $0.75 per 1,000 requests with no token price. Which tier runs depends on the clock; both are pinned here. +func TestDeepSeekBuiltinBilling(t *testing.T) { + settings := config.GlobalConfig.Get("billing_setting").(*billing_setting.BillingSetting) + saved := *settings + savedRatios, savedPrices := ratio_setting.ModelRatio2JSONString(), ratio_setting.ModelPrice2JSONString() + t.Cleanup(func() { + *settings = saved + require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(savedRatios)) + require.NoError(t, ratio_setting.UpdateModelPriceByJSONString(savedPrices)) + }) + *settings = billing_setting.BillingSetting{BillingMode: map[string]string{}, BillingExpr: map[string]string{}} + require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(`{}`)) + require.NoError(t, ratio_setting.UpdateModelPriceByJSONString(`{}`)) + + // 1000 cache-miss + 2000 cache-hit input tokens and 500 output tokens. + chat := &dto.Usage{PromptTokens: 3000, CompletionTokens: 500, PromptTokensDetails: dto.InputTokenDetails{CachedTokens: 2000}} + responses := &dto.Usage{} + service.ApplyResponsesUsage(responses, &dto.Usage{InputTokens: 3000, OutputTokens: 500, InputTokensDetails: &dto.InputTokenDetails{CachedTokens: 2000}}) + anthropic := &dto.Usage{PromptTokens: 1000, CompletionTokens: 500, PromptTokensDetails: dto.InputTokenDetails{CachedTokens: 2000}, UsageSemantic: "anthropic"} + quota := func(expression string, usage *dto.Usage) billingexpr.TieredResult { + params := service.BuildTieredTokenParams(usage, usage.UsageSemantic == "anthropic", billingexpr.UsedVars(expression)) + result, err := billingexpr.ComputeTieredQuota(&billingexpr.BillingSnapshot{ + ExprString: expression, ExprHash: billingexpr.ExprHashString(expression), GroupRatio: 1, QuotaPerUnit: 500000, + }, params) + require.NoError(t, err) + return result + } + for model, want := range map[string]map[string]int{ + // flash: 1000*0.15 + 2000*0.003 + 500*0.6 = $0.000456 off-peak. + "deepseek-flash": {"off_peak": 228, "peak": 456}, + "deepseek-v4-flash": {"off_peak": 228, "peak": 456}, + "deepseek-v4-flash-vision-exp": {"off_peak": 228, "peak": 456}, + // pro: 1000*0.66 + 2000*0.022 + 500*1.98 = $0.001694 off-peak. + "deepseek-v4-pro": {"off_peak": 847, "peak": 1694}, + } { + t.Run(model, func(t *testing.T) { + assert.Equal(t, billing_setting.BillingModeTieredExpr, billing_setting.GetBillingMode(model)) + expression, ok := billing_setting.GetBillingExpr(model) + require.True(t, ok) + for name, usage := range map[string]*dto.Usage{"chat": chat, "responses": responses, "anthropic": anthropic} { + result := quota(expression, usage) + require.Contains(t, want, result.MatchedTier, name) + assert.Equal(t, want[result.MatchedTier], result.ActualQuotaAfterGroup, name) + } + }) + } + for _, model := range []string{"deepseek-search", "deepseek-fetch"} { + expression, ok := billing_setting.GetBillingExpr(model) + require.True(t, ok, model) + result := quota(expression, &dto.Usage{}) + assert.Equal(t, billingexpr.BillingUnitRequest, result.BillingUnit, model) + assert.Equal(t, 375, result.ActualQuotaAfterGroup, model) + } +} diff --git a/setting/operation_setting/tools.go b/setting/operation_setting/tools.go index d91df6caf0..ce5d848b02 100644 --- a/setting/operation_setting/tools.go +++ b/setting/operation_setting/tools.go @@ -85,6 +85,14 @@ func seedHardcodedToolPrices(prices map[string]float64) { prices["web_search:grok*"] = 5 prices["x_search_posts"] = 5 prices["x_search_profiles"] = 10 + + // DeepSeek Search and Fetch APIs, $0.75 per 1,000 successful requests + // each (https://api-docs.deepseek.com/quick_start/pricing), for the + // searches and page fetches DeepSeek runs server-side for Claude Code and + // Codex traffic. /v1/tools/search and /v1/tools/fetch bill through the + // deepseek-search and deepseek-fetch model prices instead. + prices["deepseek_search"] = 0.75 + prices["deepseek_fetch"] = 0.75 } // ToolPriceSetting is managed by config.GlobalConfig.Register. diff --git a/setting/operation_setting/tools_price_test.go b/setting/operation_setting/tools_price_test.go index 2973a1f944..63704d1971 100644 --- a/setting/operation_setting/tools_price_test.go +++ b/setting/operation_setting/tools_price_test.go @@ -54,11 +54,15 @@ func TestToolPriceHardcodedFallbacksSurviveMissingOperatorConfig(t *testing.T) { {"web_search", "grok-4", 5}, {"x_search_posts", "", 5}, {"x_search_profiles", "", 10}, + {"deepseek_search", "", 0.75}, + {"deepseek_fetch", "", 0.75}, + {"deepseek_search", "claude-sonnet-4-5", 0.75}, } for _, tt := range tests { assert.Equal(t, tt.want, GetToolPriceForModel(tt.tool, tt.model), "%s for %q", tt.tool, tt.model) } assert.True(t, IsBuiltInToolPriceKey("bing_web_search")) + assert.True(t, IsBuiltInToolPriceKey("deepseek_fetch")) assert.True(t, IsBuiltInToolPriceKey("google_search_grounded_prompt"), "seeded only for model prefixes") } diff --git a/web/src/features/system-settings/models/__tests__/tool-price-validation.test.tsx b/web/src/features/system-settings/models/__tests__/tool-price-validation.test.tsx index 4a2e250624..6a23a33dbc 100644 --- a/web/src/features/system-settings/models/__tests__/tool-price-validation.test.tsx +++ b/web/src/features/system-settings/models/__tests__/tool-price-validation.test.tsx @@ -78,4 +78,22 @@ describe('tool price validation', () => { queryClient.clear() }) + + test('shows the DeepSeek Search and Fetch prices for a config saved before they existed', () => { + const queryClient = new QueryClient() + + render( + + + + ) + + for (const key of ['deepseek_search', 'deepseek_fetch']) { + expect( + screen.getByRole('spinbutton', { name: `Price ($/1K calls): ${key}` }) + ).toHaveValue(0.75) + } + + queryClient.clear() + }) }) diff --git a/web/src/features/system-settings/models/tool-price-settings.tsx b/web/src/features/system-settings/models/tool-price-settings.tsx index 007cf89d41..2b7e2218af 100644 --- a/web/src/features/system-settings/models/tool-price-settings.tsx +++ b/web/src/features/system-settings/models/tool-price-settings.tsx @@ -60,6 +60,8 @@ const DEFAULT_PRICES: Record = { 'web_search:grok*': 5.0, x_search_posts: 5.0, x_search_profiles: 10.0, + deepseek_search: 0.75, + deepseek_fetch: 0.75, } type ToolPriceRow = {