feat(deepseek): support V4.1 Flash, the Search and Fetch APIs, and @ reasoning modifiers (#7867)

* fix(model): find channels for @-modified model names without the memory cache

With the channel memory cache off (no Redis and MEMORY_CACHE_ENABLED
unset), channel selection matched abilities by the exact requested name,
so a name such as deepseek-flash@effort:max found no channel. Retry with
the routing name when the exact name has no channel, as the cached path
already does.

* feat(deepseek): support V4.1 Flash, the Search and Fetch APIs, and @ reasoning modifiers

Follows the DeepSeek API update of 2026-10-17.

- Default models: deepseek-flash and deepseek-v4-pro, each with -none,
  -low and -max, plus deepseek-search and deepseek-fetch. The retired
  deepseek-chat, deepseek-reasoner and deepseek-v4-flash names are no
  longer listed.
- Thinking: the -none/-low/-max tails also work on deepseek-flash.
  @thinking and @effort now reach DeepSeek's own fields on Chat
  (thinking, reasoning_effort), Anthropic (thinking, output_config.effort)
  and Responses (reasoning.effort), and take precedence over the tails.
- Built-in prices: peak/off-peak expressions for deepseek-flash (also the
  deepseek-v4-flash aliases) and deepseek-v4-pro, and $0.75 per 1,000
  requests for deepseek-search and deepseek-fetch.
- Server-side search: searches and page fetches DeepSeek runs for Claude
  Code and Codex bill at $0.75 per 1,000 under deepseek_search and
  deepseek_fetch, whatever model name the client sent. Searches whose
  results already arrived stay billed when a stream ends before its final
  usage; this also applies to Alibaba's Anthropic endpoint.
- New endpoints POST /v1/tools/search and /v1/tools/fetch relay the
  DeepSeek APIs like /v1/alpha/search. A call is charged only when
  DeepSeek returns results or page content.

* fix(deepseek): price /v1/tools/search and /v1/tools/fetch by endpoint

A model named in the request body chose the billing model, so a body such
as {"query":"...","model":"deepseek-flash"} was priced as a chat model with
no tokens and the search or fetch cost nothing. Both endpoints now always
use their routing name, deepseek-search or deepseek-fetch. DeepSeek ignores
the body field, so the body is still forwarded unchanged.

* fix(deepseek): bill Claude-format page fetches at the DeepSeek Fetch price

usage.server_tool_use.web_fetch_requests always went to the web_fetch
tool price, which has no built-in value, while only searches used the
DeepSeek key. A channel can now name its own fetch key next to the search
key, and DeepSeek channels bill reported fetches under deepseek_fetch. A
channel retry clears it like the search key.
This commit is contained in:
Calcium-Ion authored and GitHub committed 2026-10-17 23:24:33 +08:00
1 parent 6461f084ff
commit 6cf0d90b5f
37 files changed
+1144 -56

No files matched your search

+2
View File
@@ -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"},
+7
View File
@@ -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:
+10
View File
@@ -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"))
}
+9
View File
@@ -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"
)
+2
View File
@@ -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)
}
+7
View File
@@ -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") {
+21
View File
@@ -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...)
+10
View File
@@ -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)
+20
View File
@@ -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)
}
}
+117
View File
@@ -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())
})
}
}
+26 -13
View File
@@ -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
}
+65 -1
View File
@@ -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)
})
}
}
}
+101 -30
View File
@@ -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
}
}
+270
View File
@@ -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)
}
})
}
}
+13 -3
View File
@@ -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"
)
+37
View File
@@ -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
}
+14 -4
View File
@@ -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{}
+2 -1
View File
@@ -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:
+21
View File
@@ -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)
})
}
}
+7
View File
@@ -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") {
+2
View File
@@ -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) {
+127
View File
@@ -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
}
+6
View File
@@ -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))
+55
View File
@@ -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)
+24
View File
@@ -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 {
+6
View File
@@ -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:
+34
View File
@@ -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
}
}
+7 -4
View File
@@ -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
}
+2
View File
@@ -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"
+1
View File
@@ -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"
+8
View File
@@ -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.
@@ -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))`,
}
@@ -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)
}
}
+8
View File
@@ -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.
@@ -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")
}
@@ -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(
<QueryClientProvider client={queryClient}>
<ToolPriceSettings defaultValue='{"web_search":10}' />
</QueryClientProvider>
)
for (const key of ['deepseek_search', 'deepseek_fetch']) {
expect(
screen.getByRole('spinbutton', { name: `Price ($/1K calls): ${key}` })
).toHaveValue(0.75)
}
queryClient.clear()
})
})
@@ -60,6 +60,8 @@ const DEFAULT_PRICES: Record<string, number> = {
'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 = {