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:
@@ -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"))
|
||||
}
|
||||
Reference in New Issue
Block a user