feat: configurable tool pricing, Sub2API channel, and alpha search billing

Add admin-configurable tool-call prices with cross-provider surcharge
settlement, Sub2API channel support, /v1/alpha/search relay, and usage-log
surcharge UI.
This commit is contained in:
CaIon
2026-07-26 20:05:15 +08:00
parent 3e1e728279
commit 2d23cdf291
65 changed files with 3210 additions and 431 deletions
+127 -77
View File
@@ -1,10 +1,14 @@
package operation_setting
import (
"encoding/json"
"fmt"
"math"
"sort"
"strings"
"sync/atomic"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/setting/config"
)
@@ -16,39 +20,45 @@ import (
// - "tool_name" → default price for all models
// - "tool_name:model_prefix*" → override for models matching the prefix
//
// Lookup order: longest prefix match → default → hardcoded fallback → 0
// 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.
// ---------------------------------------------------------------------------
var defaultToolPrices = map[string]float64{
"web_search": 10.0, // OpenAI web search (all models) / Claude web search
"web_search_preview": 10.0, // OpenAI web search preview (default: reasoning models)
"file_search": 2.5, // OpenAI file search (Responses API)
"google_search": 14.0, // Gemini Grounding with Google Search
}
const ToolPriceOptionKey = "tool_price_setting.prices"
var defaultToolPriceOverrides = map[string]float64{
"web_search_preview:gpt-4o*": 25.0, // non-reasoning models
"web_search_preview:gpt-4.1*": 25.0,
"web_search_preview:gpt-4o-mini*": 25.0,
"web_search_preview:gpt-4.1-mini*": 25.0,
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
}
// 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: func() map[string]float64 {
m := make(map[string]float64, len(defaultToolPrices)+len(defaultToolPriceOverrides))
for k, v := range defaultToolPrices {
m[k] = v
}
for k, v := range defaultToolPriceOverrides {
m[k] = v
}
return m
}(),
Prices: make(map[string]float64),
}
func init() {
@@ -72,17 +82,77 @@ type toolPriceIndex struct {
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, fmt.Errorf("工具价格必须是 JSON 对象")
}
var rawPrices map[string]json.RawMessage
if err := common.Unmarshal(rawValue, &rawPrices); err != nil {
return nil, fmt.Errorf("解析工具价格失败: %w", err)
}
prices := make(map[string]float64, len(rawPrices))
for name, rawPrice := range rawPrices {
var entryErr error
if common.GetJsonType(rawPrice) != "number" {
entryErr = fmt.Errorf("工具价格 %q 必须是非负数字", name)
} else {
var price float64
if err := common.Unmarshal(rawPrice, &price); err != nil {
entryErr = fmt.Errorf("解析工具价格 %q 失败: %w", name, err)
} else if !isValidToolPrice(price) {
entryErr = fmt.Errorf("工具价格 %q 必须是有限的非负数字", 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("加载工具价格失败,将使用硬编码兜底: " + 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, len(defaultToolPrices)+len(defaultToolPriceOverrides)+len(toolPriceSetting.Prices))
for k, v := range defaultToolPrices {
merged[k] = v
}
for k, v := range defaultToolPriceOverrides {
merged[k] = v
}
merged := make(map[string]float64, 9+len(toolPriceSetting.Prices))
seedHardcodedToolPrices(merged)
for k, v := range toolPriceSetting.Prices {
if !isValidToolPrice(v) {
continue
}
merged[k] = v
}
@@ -106,6 +176,9 @@ func RebuildToolPriceIndex() {
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
@@ -119,10 +192,11 @@ func RebuildToolPriceIndex() {
func GetToolPriceForModel(toolName, modelName string) float64 {
idx := currentIndex.Load()
if idx == nil {
if v, ok := defaultToolPrices[toolName]; ok {
return v
RebuildToolPriceIndex()
idx = currentIndex.Load()
if idx == nil {
return 0
}
return 0
}
if entries, ok := idx.prefixes[toolName]; ok && modelName != "" {
@@ -144,48 +218,19 @@ func GetToolPrice(toolName string) float64 {
return GetToolPriceForModel(toolName, "")
}
// ---------------------------------------------------------------------------
// GPT Image 1 per-call pricing (special: depends on quality + size)
// ---------------------------------------------------------------------------
const (
GPTImage1Low1024x1024 = 0.011
GPTImage1Low1024x1536 = 0.016
GPTImage1Low1536x1024 = 0.016
GPTImage1Medium1024x1024 = 0.042
GPTImage1Medium1024x1536 = 0.063
GPTImage1Medium1536x1024 = 0.063
GPTImage1High1024x1024 = 0.167
GPTImage1High1024x1536 = 0.25
GPTImage1High1536x1024 = 0.25
)
func GetGPTImage1PriceOnceCall(quality string, size string) float64 {
prices := map[string]map[string]float64{
"low": {
"1024x1024": GPTImage1Low1024x1024,
"1024x1536": GPTImage1Low1024x1536,
"1536x1024": GPTImage1Low1536x1024,
},
"medium": {
"1024x1024": GPTImage1Medium1024x1024,
"1024x1536": GPTImage1Medium1024x1536,
"1536x1024": GPTImage1Medium1536x1024,
},
"high": {
"1024x1024": GPTImage1High1024x1024,
"1024x1536": GPTImage1High1024x1536,
"1536x1024": GPTImage1High1536x1024,
},
// 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()
}
if qualityMap, exists := prices[quality]; exists {
if price, exists := qualityMap[size]; exists {
return price
}
}
return GPTImage1High1024x1024
// DeleteToolPriceForTest removes an injected tool price and rebuilds the index. Tests only.
func DeleteToolPriceForTest(name string) {
delete(toolPriceSetting.Prices, name)
RebuildToolPriceIndex()
}
// ---------------------------------------------------------------------------
@@ -204,15 +249,20 @@ const (
func GetGeminiInputAudioPricePerMillionTokens(modelName string) float64 {
if strings.HasPrefix(modelName, "gemini-2.5-flash-preview-native-audio") {
return Gemini25FlashNativeAudioInputAudioPrice
} else if strings.HasPrefix(modelName, "gemini-2.5-flash-preview-lite") {
}
if strings.HasPrefix(modelName, "gemini-2.5-flash-preview-lite") {
return Gemini25FlashLitePreviewInputAudioPrice
} else if strings.HasPrefix(modelName, "gemini-2.5-flash-preview") {
}
if strings.HasPrefix(modelName, "gemini-2.5-flash-preview") {
return Gemini25FlashPreviewInputAudioPrice
} else if strings.HasPrefix(modelName, "gemini-2.5-flash") {
}
if strings.HasPrefix(modelName, "gemini-2.5-flash") {
return Gemini25FlashProductionInputAudioPrice
} else if strings.HasPrefix(modelName, "gemini-2.0-flash") {
}
if strings.HasPrefix(modelName, "gemini-2.0-flash") {
return Gemini20FlashInputAudioPrice
} else if strings.HasPrefix(modelName, "gemini-robotics-er-1.5") {
}
if strings.HasPrefix(modelName, "gemini-robotics-er-1.5") {
return GeminiRoboticsER15InputAudioPrice
}
return 0
@@ -0,0 +1,155 @@
package operation_setting
import (
"math"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func preserveToolPrices(t *testing.T) {
t.Helper()
original := make(map[string]float64, len(toolPriceSetting.Prices))
for key, price := range toolPriceSetting.Prices {
original[key] = price
}
t.Cleanup(func() {
toolPriceSetting.Prices = original
RebuildToolPriceIndex()
})
}
func TestToolPriceHardcodedFallbacksSurviveMissingOperatorConfig(t *testing.T) {
preserveToolPrices(t)
toolPriceSetting.Prices = map[string]float64{}
RebuildToolPriceIndex()
expectedDefaults := map[string]float64{
"web_search": 10,
"web_search_preview": 10,
"file_search": 2.5,
"google_search": 14,
"image_generation": 150,
}
for name, expected := range expectedDefaults {
assert.Equal(t, expected, GetToolPrice(name), name)
}
assert.Equal(t, 25.0, GetToolPriceForModel("web_search_preview", "gpt-4o-2024-11-20"))
assert.Equal(t, 25.0, GetToolPriceForModel("web_search_preview", "gpt-4.1-mini"))
}
func TestToolPriceOperatorOverridePrecedenceAndExplicitZero(t *testing.T) {
preserveToolPrices(t)
toolPriceSetting.Prices = map[string]float64{
"image_generation": 0,
"web_search": 12,
"web_search_preview": 0,
"web_search_preview:gpt-4o*": 30,
"web_search_preview:gpt-4o-mini*": 0,
"web_search_preview:custom-model*": 7,
}
RebuildToolPriceIndex()
assert.Equal(t, 0.0, GetToolPrice("image_generation"))
assert.Equal(t, 12.0, GetToolPrice("web_search"))
assert.Equal(t, 0.0, GetToolPriceForModel("web_search_preview", "o1"))
assert.Equal(t, 30.0, GetToolPriceForModel("web_search_preview", "gpt-4o"))
assert.Equal(t, 0.0, GetToolPriceForModel("web_search_preview", "gpt-4o-mini"))
assert.Equal(t, 25.0, GetToolPriceForModel("web_search_preview", "gpt-4.1"))
assert.Equal(t, 7.0, GetToolPriceForModel("web_search_preview", "custom-model-v2"))
delete(toolPriceSetting.Prices, "web_search_preview:gpt-4o*")
RebuildToolPriceIndex()
assert.Equal(t, 25.0, GetToolPriceForModel("web_search_preview", "gpt-4o"))
delete(toolPriceSetting.Prices, "web_search")
RebuildToolPriceIndex()
assert.Equal(t, 10.0, GetToolPrice("web_search"))
}
func TestToolPriceCustomFunctionHasNoHardcodedFallback(t *testing.T) {
preserveToolPrices(t)
toolPriceSetting.Prices = map[string]float64{}
RebuildToolPriceIndex()
assert.Equal(t, 0.0, GetToolPrice("lookup_customer"))
toolPriceSetting.Prices["lookup_customer"] = 5
RebuildToolPriceIndex()
assert.Equal(t, 5.0, GetToolPrice("lookup_customer"))
toolPriceSetting.Prices["lookup_customer"] = 0
RebuildToolPriceIndex()
assert.Equal(t, 0.0, GetToolPrice("lookup_customer"))
}
func TestValidateToolPricesJSON(t *testing.T) {
valid := []string{
`{}`,
`{"web_search":0}`,
`{"web_search":10,"custom_fn":2.5}`,
}
for _, value := range valid {
assert.NoError(t, ValidateToolPricesJSON(value), value)
}
invalid := []string{
`null`,
`[]`,
`{"web_search":null}`,
`{"web_search":true}`,
`{"web_search":"0"}`,
`{"web_search":-1}`,
`{"web_search":1e999}`,
`{"web_search":`,
}
for _, value := range invalid {
assert.Error(t, ValidateToolPricesJSON(value), value)
}
}
func TestLoadToolPricesFromJSONStringReplacesMapAndKeepsValidSiblings(t *testing.T) {
preserveToolPrices(t)
LoadToolPricesFromJSONString(`{
"web_search": 0,
"custom_fn": 3,
"file_search": null,
"google_search": -1,
"image_generation": "0"
}`)
require.Len(t, toolPriceSetting.Prices, 2)
assert.Equal(t, 0.0, toolPriceSetting.Prices["web_search"])
assert.Equal(t, 3.0, toolPriceSetting.Prices["custom_fn"])
assert.Equal(t, 0.0, GetToolPrice("web_search"))
assert.Equal(t, 3.0, GetToolPrice("custom_fn"))
assert.Equal(t, 2.5, GetToolPrice("file_search"))
assert.Equal(t, 14.0, GetToolPrice("google_search"))
assert.Equal(t, 150.0, GetToolPrice("image_generation"))
LoadToolPricesFromJSONString(`{"image_generation":0}`)
require.Len(t, toolPriceSetting.Prices, 1)
assert.NotContains(t, toolPriceSetting.Prices, "web_search")
assert.NotContains(t, toolPriceSetting.Prices, "custom_fn")
assert.Equal(t, 10.0, GetToolPrice("web_search"))
assert.Equal(t, 0.0, GetToolPrice("custom_fn"))
assert.Equal(t, 0.0, GetToolPrice("image_generation"))
}
func TestRebuildToolPriceIndexIgnoresInvalidDirectValues(t *testing.T) {
preserveToolPrices(t)
toolPriceSetting.Prices = map[string]float64{
"web_search": -1,
"file_search": math.Inf(1),
"image_generation": math.NaN(),
"custom_fn": math.NaN(),
}
RebuildToolPriceIndex()
assert.Equal(t, 10.0, GetToolPrice("web_search"))
assert.Equal(t, 2.5, GetToolPrice("file_search"))
assert.Equal(t, 150.0, GetToolPrice("image_generation"))
assert.Equal(t, 0.0, GetToolPrice("custom_fn"))
}