mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-10-28 08:35:33 +08:00
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:
1 parent
6461f084ff
commit
6cf0d90b5f
37 files changed
+1144
-56
No files matched your search
@@ -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"},
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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...)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
Reference in new issue
Block a user