mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-10-28 08:35:33 +08:00
* 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.
332 lines
11 KiB
Go
332 lines
11 KiB
Go
package operation_setting
|
|
|
|
import (
|
|
"encoding/json"
|
|
"math"
|
|
"sort"
|
|
"strings"
|
|
"sync/atomic"
|
|
|
|
"github.com/QuantumNous/new-api/common"
|
|
"github.com/QuantumNous/new-api/setting/config"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tool call prices ($/1K calls, admin-configurable)
|
|
// DB key: tool_price_setting.prices
|
|
//
|
|
// Key format:
|
|
// - "tool_name" → default price for all models
|
|
// - "tool_name:model_prefix*" → override for models matching the prefix
|
|
//
|
|
// Effective index: hardcoded defaults → hardcoded model overrides → valid
|
|
// operator values. Lookup uses the longest model prefix before the tool
|
|
// default, and a matched numeric zero is terminal.
|
|
//
|
|
// Built-in keys are written in the vendor's list currency.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const ToolPriceOptionKey = "tool_price_setting.prices"
|
|
|
|
const (
|
|
defaultWebSearchToolPrice = 10.0
|
|
defaultWebSearchPreviewToolPrice = 10.0
|
|
defaultFileSearchToolPrice = 2.5
|
|
defaultGoogleSearchToolPrice = 14.0
|
|
defaultImageGenerationToolPrice = 150.0
|
|
defaultSearchPreviewModelPrice = 25.0
|
|
)
|
|
|
|
// seedHardcodedToolPrices injects compile-time built-in fallbacks (tool
|
|
// defaults and model-prefix overrides) into the destination. The source is
|
|
// constants, not a mutable package map or operator configuration.
|
|
func seedHardcodedToolPrices(prices map[string]float64) {
|
|
prices["web_search"] = defaultWebSearchToolPrice
|
|
prices["web_search_preview"] = defaultWebSearchPreviewToolPrice
|
|
prices["file_search"] = defaultFileSearchToolPrice
|
|
prices["google_search"] = defaultGoogleSearchToolPrice
|
|
prices["image_generation"] = defaultImageGenerationToolPrice
|
|
prices["web_search_preview:gpt-4o*"] = defaultSearchPreviewModelPrice
|
|
prices["web_search_preview:gpt-4.1*"] = defaultSearchPreviewModelPrice
|
|
prices["web_search_preview:gpt-4o-mini*"] = defaultSearchPreviewModelPrice
|
|
prices["web_search_preview:gpt-4.1-mini*"] = defaultSearchPreviewModelPrice
|
|
|
|
// Google Search grounding (USD per 1K, ai.google.dev/gemini-api/docs/pricing
|
|
// and cloud.google.com/vertex-ai/generative-ai/pricing): Gemini 3 bills each
|
|
// search query at $14 (the google_search default); Gemini 2.5 and older bill
|
|
// $35 per grounded prompt, so their query count is free and the grounded
|
|
// prompt carries the price.
|
|
prices["google_search:gemini-2.5*"] = 0
|
|
prices["google_search:gemini-2.0*"] = 0
|
|
prices["google_search:gemini-1.5*"] = 0
|
|
prices["google_search_grounded_prompt:gemini-2.5*"] = 35
|
|
prices["google_search_grounded_prompt:gemini-2.0*"] = 35
|
|
prices["google_search_grounded_prompt:gemini-1.5*"] = 35
|
|
|
|
// Vendor search tiers, verbatim in the vendor's list currency per 1K
|
|
// calls. CNY: Zhipu search engines, 0.01/0.03/0.05 CNY per call
|
|
// (https://docs.bigmodel.cn/cn/guide/tools/web-search), and Alibaba Model
|
|
// Studio search strategies at Beijing prices
|
|
// (https://help.aliyun.com/zh/model-studio/web-search); the Singapore
|
|
// agent price (73.392381 CNY) is set by the operator. USD: Azure
|
|
// Responses web search, billed as Bing transactions at $14 per 1,000
|
|
// (https://www.microsoft.com/en-us/bing/apis), and xAI web search $5 per
|
|
// 1K calls, x_search $5 per 1K posts and $10 per 1K profiles
|
|
// (https://docs.x.ai/developers/pricing).
|
|
prices["search_std"] = 10
|
|
prices["search_pro"] = 30
|
|
prices["search_pro_sogou"] = 50
|
|
prices["search_pro_quark"] = 50
|
|
prices["search_strategy_turbo"] = 3
|
|
prices["search_strategy_max"] = 4
|
|
prices["search_strategy_agent"] = 4
|
|
prices["search_strategy_agent_max"] = 4
|
|
prices["bing_web_search"] = 14
|
|
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.
|
|
// Prices holds operator overrides only; hardcoded fallbacks live in the index.
|
|
type ToolPriceSetting struct {
|
|
Prices map[string]float64 `json:"prices"`
|
|
}
|
|
|
|
var toolPriceSetting = ToolPriceSetting{
|
|
Prices: make(map[string]float64),
|
|
}
|
|
|
|
// builtInToolNames are the tool names (the part before ":") that
|
|
// seedHardcodedToolPrices prices, built once from the constant seed and never
|
|
// from operator configuration.
|
|
var builtInToolNames = make(map[string]struct{})
|
|
|
|
func init() {
|
|
config.GlobalConfig.Register("tool_price_setting", &toolPriceSetting)
|
|
seed := make(map[string]float64)
|
|
seedHardcodedToolPrices(seed)
|
|
for key := range seed {
|
|
name, _, _ := strings.Cut(key, ":")
|
|
builtInToolNames[name] = struct{}{}
|
|
}
|
|
RebuildToolPriceIndex()
|
|
}
|
|
|
|
// IsBuiltInToolPriceKey reports whether the built-in price seed prices the
|
|
// tool name, as a default or for a model prefix. Operator prices never count.
|
|
func IsBuiltInToolPriceKey(name string) bool {
|
|
_, ok := builtInToolNames[name]
|
|
return ok
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Precomputed price index (atomic, lock-free on read path)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type prefixEntry struct {
|
|
prefix string
|
|
price float64
|
|
}
|
|
|
|
type toolPriceIndex struct {
|
|
defaults map[string]float64
|
|
prefixes map[string][]prefixEntry
|
|
}
|
|
|
|
var currentIndex atomic.Pointer[toolPriceIndex]
|
|
|
|
func isValidToolPrice(price float64) bool {
|
|
return price >= 0 && !math.IsNaN(price) && !math.IsInf(price, 0)
|
|
}
|
|
|
|
func decodeToolPricesJSON(value string, ignoreInvalidEntries bool) (map[string]float64, error) {
|
|
rawValue := json.RawMessage(strings.TrimSpace(value))
|
|
if common.GetJsonType(rawValue) != "object" {
|
|
return nil, common.NewMessage("Tool prices must be a JSON object")
|
|
}
|
|
|
|
var rawPrices map[string]json.RawMessage
|
|
if err := common.Unmarshal(rawValue, &rawPrices); err != nil {
|
|
return nil, common.NewMessage("Failed to parse tool prices: {{error}}", map[string]any{"error": err.Error()})
|
|
}
|
|
|
|
prices := make(map[string]float64, len(rawPrices))
|
|
for name, rawPrice := range rawPrices {
|
|
var entryErr error
|
|
if common.GetJsonType(rawPrice) != "number" {
|
|
entryErr = common.NewMessage(`Tool price "{{name}}" must be a non-negative number`, map[string]any{"name": name})
|
|
} else {
|
|
var price float64
|
|
if err := common.Unmarshal(rawPrice, &price); err != nil {
|
|
entryErr = common.NewMessage(`Failed to parse tool price "{{name}}": {{error}}`, map[string]any{"name": name, "error": err.Error()})
|
|
} else if !isValidToolPrice(price) {
|
|
entryErr = common.NewMessage(`Tool price "{{name}}" must be a finite non-negative number`, map[string]any{"name": name})
|
|
} else {
|
|
prices[name] = price
|
|
}
|
|
}
|
|
|
|
if entryErr == nil {
|
|
continue
|
|
}
|
|
if !ignoreInvalidEntries {
|
|
return nil, entryErr
|
|
}
|
|
common.SysError(entryErr.Error())
|
|
}
|
|
return prices, nil
|
|
}
|
|
|
|
// ValidateToolPricesJSON validates an operator-supplied complete price map.
|
|
// A numeric zero is valid and intentionally disables the matching rule.
|
|
func ValidateToolPricesJSON(value string) error {
|
|
_, err := decodeToolPricesJSON(value, false)
|
|
return err
|
|
}
|
|
|
|
// LoadToolPricesFromJSONString replaces the complete operator price map.
|
|
// Invalid legacy entries are ignored individually so valid sibling overrides
|
|
// survive, while missing built-in keys continue to use hardcoded fallbacks.
|
|
func LoadToolPricesFromJSONString(value string) {
|
|
prices, err := decodeToolPricesJSON(value, true)
|
|
if err != nil {
|
|
common.SysError(common.LogText("failed to load tool prices, using hardcoded fallbacks: %s", err.Error()))
|
|
prices = make(map[string]float64)
|
|
}
|
|
toolPriceSetting.Prices = prices
|
|
RebuildToolPriceIndex()
|
|
}
|
|
|
|
// RebuildToolPriceIndex rebuilds the lookup index from the current config.
|
|
// Called on init and after config updates. Not on the billing hot path.
|
|
func RebuildToolPriceIndex() {
|
|
merged := make(map[string]float64, 9+len(toolPriceSetting.Prices))
|
|
seedHardcodedToolPrices(merged)
|
|
for k, v := range toolPriceSetting.Prices {
|
|
if !isValidToolPrice(v) {
|
|
continue
|
|
}
|
|
merged[k] = v
|
|
}
|
|
|
|
idx := &toolPriceIndex{
|
|
defaults: make(map[string]float64),
|
|
prefixes: make(map[string][]prefixEntry),
|
|
}
|
|
|
|
for key, price := range merged {
|
|
before, after, ok := strings.Cut(key, ":")
|
|
if !ok {
|
|
idx.defaults[key] = price
|
|
continue
|
|
}
|
|
toolName := before
|
|
modelPart := after
|
|
prefix := strings.TrimSuffix(modelPart, "*")
|
|
idx.prefixes[toolName] = append(idx.prefixes[toolName], prefixEntry{prefix: prefix, price: price})
|
|
}
|
|
|
|
for tool := range idx.prefixes {
|
|
entries := idx.prefixes[tool]
|
|
sort.Slice(entries, func(i, j int) bool {
|
|
if len(entries[i].prefix) == len(entries[j].prefix) {
|
|
return entries[i].prefix < entries[j].prefix
|
|
}
|
|
return len(entries[i].prefix) > len(entries[j].prefix)
|
|
})
|
|
idx.prefixes[tool] = entries
|
|
}
|
|
|
|
currentIndex.Store(idx)
|
|
}
|
|
|
|
// GetToolPriceForModel returns the price ($/1K calls) for a tool given a model name.
|
|
// Lookup: longest prefix match → tool default → 0.
|
|
func GetToolPriceForModel(toolName, modelName string) float64 {
|
|
idx := currentIndex.Load()
|
|
if idx == nil {
|
|
RebuildToolPriceIndex()
|
|
idx = currentIndex.Load()
|
|
if idx == nil {
|
|
return 0
|
|
}
|
|
}
|
|
|
|
if entries, ok := idx.prefixes[toolName]; ok && modelName != "" {
|
|
for _, e := range entries {
|
|
if strings.HasPrefix(modelName, e.prefix) {
|
|
return e.price
|
|
}
|
|
}
|
|
}
|
|
|
|
if p, ok := idx.defaults[toolName]; ok {
|
|
return p
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// GetToolPrice is a convenience wrapper when no model name is needed.
|
|
func GetToolPrice(toolName string) float64 {
|
|
return GetToolPriceForModel(toolName, "")
|
|
}
|
|
|
|
// SetToolPriceForTest injects a tool price and rebuilds the lookup index. Tests only.
|
|
func SetToolPriceForTest(name string, price float64) {
|
|
if toolPriceSetting.Prices == nil {
|
|
toolPriceSetting.Prices = make(map[string]float64)
|
|
}
|
|
toolPriceSetting.Prices[name] = price
|
|
RebuildToolPriceIndex()
|
|
}
|
|
|
|
// DeleteToolPriceForTest removes an injected tool price and rebuilds the index. Tests only.
|
|
func DeleteToolPriceForTest(name string) {
|
|
delete(toolPriceSetting.Prices, name)
|
|
RebuildToolPriceIndex()
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Gemini audio input pricing (per-million tokens, model-specific)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const (
|
|
Gemini25FlashPreviewInputAudioPrice = 1.00
|
|
Gemini25FlashProductionInputAudioPrice = 1.00
|
|
Gemini25FlashLitePreviewInputAudioPrice = 0.50
|
|
Gemini25FlashNativeAudioInputAudioPrice = 3.00
|
|
Gemini20FlashInputAudioPrice = 0.70
|
|
GeminiRoboticsER15InputAudioPrice = 1.00
|
|
)
|
|
|
|
func GetGeminiInputAudioPricePerMillionTokens(modelName string) float64 {
|
|
if strings.HasPrefix(modelName, "gemini-2.5-flash-preview-native-audio") {
|
|
return Gemini25FlashNativeAudioInputAudioPrice
|
|
}
|
|
if strings.HasPrefix(modelName, "gemini-2.5-flash-preview-lite") {
|
|
return Gemini25FlashLitePreviewInputAudioPrice
|
|
}
|
|
if strings.HasPrefix(modelName, "gemini-2.5-flash-preview") {
|
|
return Gemini25FlashPreviewInputAudioPrice
|
|
}
|
|
if strings.HasPrefix(modelName, "gemini-2.5-flash") {
|
|
return Gemini25FlashProductionInputAudioPrice
|
|
}
|
|
if strings.HasPrefix(modelName, "gemini-2.0-flash") {
|
|
return Gemini20FlashInputAudioPrice
|
|
}
|
|
if strings.HasPrefix(modelName, "gemini-robotics-er-1.5") {
|
|
return GeminiRoboticsER15InputAudioPrice
|
|
}
|
|
return 0
|
|
}
|