Files
new-api/model/task_plugin_channel_select_test.go
Calcium-Ion 6cf0d90b5f 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.
2026-10-17 23:24:33 +08:00

140 lines
6.9 KiB
Go

package model
import (
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTaskPluginChannelSelectionFiltersBothCachePaths(t *testing.T) {
truncateTables(t)
priority := int64(0)
weight := uint(1)
baseURL := "https://example.com"
alphaSetting := `{"task_plugin_key":"alpha"}`
betaSetting := `{"task_plugin_key":"beta"}`
channels := []Channel{
{Id: 900001, Type: constant.ChannelTypeTaskPlugin, Status: common.ChannelStatusEnabled, Name: "alpha", Models: "shared", Group: "default", Priority: &priority, Weight: &weight, BaseURL: &baseURL, Setting: &alphaSetting},
{Id: 900002, Type: constant.ChannelTypeTaskPlugin, Status: common.ChannelStatusEnabled, Name: "beta", Models: "shared", Group: "default", Priority: &priority, Weight: &weight, BaseURL: &baseURL, Setting: &betaSetting},
{Id: 900003, Type: constant.ChannelTypeOpenAI, Status: common.ChannelStatusEnabled, Name: "ordinary", Models: "ordinary", Group: "default", Priority: &priority, Weight: &weight},
{Id: 900004, Type: constant.ChannelTypeKling, Status: common.ChannelStatusEnabled, Name: "legacy-alpha", Models: "legacy", Group: "default", Priority: &priority, Weight: &weight},
{Id: 900005, Type: constant.ChannelTypeJimeng, Status: common.ChannelStatusEnabled, Name: "legacy-beta", Models: "legacy", Group: "default", Priority: &priority, Weight: &weight},
}
for i := range channels {
require.NoError(t, channels[i].Insert())
}
selected, err := GetChannel("default", "shared", 0, identityFilters("alpha", nil))
require.NoError(t, err)
require.NotNil(t, selected)
assert.Equal(t, "alpha", selected.Name)
selected, err = GetChannel("default", "shared", 0, identityFilters("", nil))
require.NoError(t, err)
assert.Nil(t, selected)
selected, err = GetChannel("default", "ordinary", 0, identityFilters("", nil))
require.NoError(t, err)
require.NotNil(t, selected)
assert.Equal(t, "ordinary", selected.Name)
selected, err = GetChannel("default", "legacy", 0, identityFilters("legacy-alpha", []int{constant.ChannelTypeKling}))
require.NoError(t, err)
require.NotNil(t, selected)
assert.Equal(t, "legacy-alpha", selected.Name)
selected, err = GetChannel("default", "legacy", 0, identityFilters("legacy-alpha", []int{constant.ChannelTypeKling, constant.ChannelTypeJimeng}))
require.NoError(t, err)
require.NotNil(t, selected)
assert.Contains(t, []string{"legacy-alpha", "legacy-beta"}, selected.Name)
}
func identityFilters(key string, channelTypes []int) []dto.ChannelFilter {
return []dto.ChannelFilter{{
Kind: dto.FilterTaskPluginIdentity,
TaskPluginKey: key,
TaskPluginChannelTypes: channelTypes,
}}
}
func TestSharedPluginKeysFilterBothChannelSources(t *testing.T) {
truncateTables(t)
originalMemoryCache := common.MemoryCacheEnabled
common.MemoryCacheEnabled = true
t.Cleanup(func() { common.MemoryCacheEnabled = originalMemoryCache; InitChannelCache() })
filters := []dto.ChannelFilter{{Kind: dto.FilterTaskPluginIdentity, TaskPluginKey: "alpha", TaskPluginKeys: []string{"alpha", "beta"}}}
var abilities []Ability
for index, key := range []string{"alpha", "beta", "unrelated"} {
setting := `{"task_plugin_key":"` + key + `"}`
channel := Channel{Id: 910001 + index, Type: constant.ChannelTypeTaskPlugin, Status: common.ChannelStatusEnabled, Name: key, Models: "shared", Group: "default", Setting: &setting}
require.NoError(t, channel.Insert())
abilities = append(abilities, Ability{ChannelId: channel.Id, Model: "shared", Group: "default", Enabled: true})
matches, _ := ChannelSatisfiesFilters(&channel, "shared", filters)
assert.Equal(t, index < 2, matches)
}
assert.Equal(t, abilities[:2], filterAbilitiesByConstraints(abilities, "shared", filters))
InitChannelCache()
channelSyncLock.RLock()
kept, emptied := filterCandidateIDs([]int{910001, 910002, 910003}, "shared", filters)
channelSyncLock.RUnlock()
assert.Equal(t, []int{910001, 910002}, kept)
assert.Empty(t, emptied)
}
func TestNewAPIChannelServesExtendedTaskPlugins(t *testing.T) {
truncateTables(t)
priority := int64(0)
weight := uint(1)
baseURL := "https://gateway.example"
extended := `{"task_extend_plugin_keys":["alpha","gamma"]}`
single := `{"task_plugin_key":"delta"}`
channels := []Channel{
{Id: 920001, Type: constant.ChannelTypeNewAPI, Status: common.ChannelStatusEnabled, Name: "gateway", Models: "shared,chat", Group: "default", Priority: &priority, Weight: &weight, BaseURL: &baseURL, Setting: &extended},
{Id: 920002, Type: constant.ChannelTypeNewAPI, Status: common.ChannelStatusEnabled, Name: "gateway-single", Models: "shared", Group: "default", Priority: &priority, Weight: &weight, BaseURL: &baseURL, Setting: &single},
}
for i := range channels {
require.NoError(t, channels[i].Insert())
}
selected, err := GetChannel("default", "shared", 0, identityFilters("alpha", nil))
require.NoError(t, err)
require.NotNil(t, selected)
assert.Equal(t, "gateway", selected.Name)
selected, err = GetChannel("default", "shared", 0, identityFilters("delta", nil))
require.NoError(t, err)
require.NotNil(t, selected)
assert.Equal(t, "gateway-single", selected.Name, "a single task_plugin_key still binds a New API channel")
selected, err = GetChannel("default", "shared", 0, identityFilters("beta", nil))
require.NoError(t, err)
assert.Nil(t, selected, "unbound plugins never reach the gateway channel")
shared := []dto.ChannelFilter{{Kind: dto.FilterTaskPluginIdentity, TaskPluginKey: "beta", TaskPluginKeys: []string{"beta", "gamma"}}}
selected, err = GetChannel("default", "shared", 0, shared)
require.NoError(t, err)
require.NotNil(t, selected)
assert.Equal(t, "gateway", selected.Name, "a bound shared-model candidate admits the channel")
selected, err = GetChannel("default", "chat", 0, identityFilters("", nil))
require.NoError(t, err)
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)
}
}