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 = {