feat: add billing expression system documentation and enhance tiered billing logic
- Introduced a new rule for the Billing Expression System, emphasizing the importance of reading `pkg/billingexpr/expr.md` for dynamic billing. - Updated the billing expression logic to support new variables and improved handling of image and audio tokens. - Enhanced the tiered billing functionality with versioning support for expressions and refined quota calculations. - Added tests to validate the new billing expression features and ensure correctness in pricing calculations.
This commit is contained in:
@@ -2,20 +2,9 @@ package billing_setting
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/pkg/billingexpr"
|
||||
)
|
||||
|
||||
var (
|
||||
mu sync.RWMutex
|
||||
|
||||
// model -> "ratio" | "tiered_expr"
|
||||
billingModeMap = make(map[string]string)
|
||||
|
||||
// model -> expr string (authored by frontend, stored directly)
|
||||
billingExprMap = make(map[string]string)
|
||||
"github.com/QuantumNous/new-api/setting/config"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -23,84 +12,44 @@ const (
|
||||
BillingModeTieredExpr = "tiered_expr"
|
||||
)
|
||||
|
||||
// BillingSetting is managed by config.GlobalConfig.Register.
|
||||
// DB keys: billing_setting.billing_mode, billing_setting.billing_expr
|
||||
type BillingSetting struct {
|
||||
BillingMode map[string]string `json:"billing_mode"`
|
||||
BillingExpr map[string]string `json:"billing_expr"`
|
||||
}
|
||||
|
||||
var billingSetting = BillingSetting{
|
||||
BillingMode: make(map[string]string),
|
||||
BillingExpr: make(map[string]string),
|
||||
}
|
||||
|
||||
func init() {
|
||||
config.GlobalConfig.Register("billing_setting", &billingSetting)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Read accessors (hot path, must be fast)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func GetBillingMode(model string) string {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
if mode, ok := billingModeMap[model]; ok {
|
||||
if mode, ok := billingSetting.BillingMode[model]; ok {
|
||||
return mode
|
||||
}
|
||||
return BillingModeRatio
|
||||
}
|
||||
|
||||
func GetBillingExpr(model string) (string, bool) {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
expr, ok := billingExprMap[model]
|
||||
expr, ok := billingSetting.BillingExpr[model]
|
||||
return expr, ok
|
||||
}
|
||||
|
||||
func UpdateBillingModeByJSONString(jsonStr string) error {
|
||||
var m map[string]string
|
||||
if err := common.Unmarshal([]byte(jsonStr), &m); err != nil {
|
||||
return fmt.Errorf("parse ModelBillingMode: %w", err)
|
||||
}
|
||||
for k, v := range m {
|
||||
if v != BillingModeRatio && v != BillingModeTieredExpr {
|
||||
return fmt.Errorf("invalid billing mode %q for model %q", v, k)
|
||||
}
|
||||
}
|
||||
mu.Lock()
|
||||
billingModeMap = m
|
||||
mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func UpdateBillingExprByJSONString(jsonStr string) error {
|
||||
var m map[string]string
|
||||
if err := common.Unmarshal([]byte(jsonStr), &m); err != nil {
|
||||
return fmt.Errorf("parse ModelBillingExpr: %w", err)
|
||||
}
|
||||
for model, exprStr := range m {
|
||||
if _, err := billingexpr.CompileFromCache(exprStr); err != nil {
|
||||
return fmt.Errorf("model %q: %w", model, err)
|
||||
}
|
||||
if err := smokeTestExpr(exprStr); err != nil {
|
||||
return fmt.Errorf("model %q smoke test: %w", model, err)
|
||||
}
|
||||
}
|
||||
mu.Lock()
|
||||
billingExprMap = m
|
||||
mu.Unlock()
|
||||
billingexpr.InvalidateCache()
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON serializers (for OptionMap / API response)
|
||||
// Smoke test (called externally for validation before save)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func BillingMode2JSONString() string {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
b, err := common.Marshal(billingModeMap)
|
||||
if err != nil {
|
||||
return "{}"
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func BillingExpr2JSONString() string {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
b, err := common.Marshal(billingExprMap)
|
||||
if err != nil {
|
||||
return "{}"
|
||||
}
|
||||
return string(b)
|
||||
func SmokeTestExpr(exprStr string) error {
|
||||
return smokeTestExpr(exprStr)
|
||||
}
|
||||
|
||||
func smokeTestExpr(exprStr string) error {
|
||||
|
||||
@@ -1,15 +1,58 @@
|
||||
package operation_setting
|
||||
|
||||
import "strings"
|
||||
import (
|
||||
"strings"
|
||||
|
||||
const (
|
||||
// Web search
|
||||
WebSearchPriceHigh = 25.00
|
||||
WebSearchPrice = 10.00
|
||||
// File search
|
||||
FileSearchPrice = 2.5
|
||||
"github.com/QuantumNous/new-api/setting/config"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool call prices ($/1K calls, admin-configurable)
|
||||
// DB keys: tool_price_setting.prices
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var defaultToolPrices = map[string]float64{
|
||||
"web_search": 10.0,
|
||||
"web_search_high": 25.0,
|
||||
"claude_web_search": 10.0,
|
||||
"file_search": 2.5,
|
||||
}
|
||||
|
||||
// ToolPriceSetting is managed by config.GlobalConfig.Register.
|
||||
type ToolPriceSetting struct {
|
||||
Prices map[string]float64 `json:"prices"`
|
||||
}
|
||||
|
||||
var toolPriceSetting = ToolPriceSetting{
|
||||
Prices: func() map[string]float64 {
|
||||
m := make(map[string]float64, len(defaultToolPrices))
|
||||
for k, v := range defaultToolPrices {
|
||||
m[k] = v
|
||||
}
|
||||
return m
|
||||
}(),
|
||||
}
|
||||
|
||||
func init() {
|
||||
config.GlobalConfig.Register("tool_price_setting", &toolPriceSetting)
|
||||
}
|
||||
|
||||
// GetToolPrice returns the configured price for a tool key ($/1K calls),
|
||||
// falling back to hardcoded default if not overridden.
|
||||
func GetToolPrice(key string) float64 {
|
||||
if v, ok := toolPriceSetting.Prices[key]; ok {
|
||||
return v
|
||||
}
|
||||
if v, ok := defaultToolPrices[key]; ok {
|
||||
return v
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GPT Image 1 per-call pricing (special: depends on quality + size)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const (
|
||||
GPTImage1Low1024x1024 = 0.011
|
||||
GPTImage1Low1024x1536 = 0.016
|
||||
@@ -22,65 +65,6 @@ const (
|
||||
GPTImage1High1536x1024 = 0.25
|
||||
)
|
||||
|
||||
const (
|
||||
// Gemini Audio Input Price
|
||||
Gemini25FlashPreviewInputAudioPrice = 1.00
|
||||
Gemini25FlashProductionInputAudioPrice = 1.00 // for `gemini-2.5-flash`
|
||||
Gemini25FlashLitePreviewInputAudioPrice = 0.50
|
||||
Gemini25FlashNativeAudioInputAudioPrice = 3.00
|
||||
Gemini20FlashInputAudioPrice = 0.70
|
||||
GeminiRoboticsER15InputAudioPrice = 1.00
|
||||
)
|
||||
|
||||
const (
|
||||
// Claude Web search
|
||||
ClaudeWebSearchPrice = 10.00
|
||||
)
|
||||
|
||||
func GetClaudeWebSearchPricePerThousand() float64 {
|
||||
return ClaudeWebSearchPrice
|
||||
}
|
||||
|
||||
func GetWebSearchPricePerThousand(modelName string, contextSize string) float64 {
|
||||
// 确定模型类型
|
||||
// https://platform.openai.com/docs/pricing Web search 价格按模型类型收费
|
||||
// 新版计费规则不再关联 search context size,故在const区域将各size的价格设为一致。
|
||||
// gpt-5, gpt-5-mini, gpt-5-nano 和 o 系列模型价格为 10.00 美元/千次调用,产生额外 token 计入 input_tokens
|
||||
// gpt-4o, gpt-4.1, gpt-4o-mini 和 gpt-4.1-mini 价格为 25.00 美元/千次调用,不产生额外 token
|
||||
isNormalPriceModel :=
|
||||
strings.HasPrefix(modelName, "o3") ||
|
||||
strings.HasPrefix(modelName, "o4") ||
|
||||
strings.HasPrefix(modelName, "gpt-5")
|
||||
var priceWebSearchPerThousandCalls float64
|
||||
if isNormalPriceModel {
|
||||
priceWebSearchPerThousandCalls = WebSearchPrice
|
||||
} else {
|
||||
priceWebSearchPerThousandCalls = WebSearchPriceHigh
|
||||
}
|
||||
return priceWebSearchPerThousandCalls
|
||||
}
|
||||
|
||||
func GetFileSearchPricePerThousand() float64 {
|
||||
return FileSearchPrice
|
||||
}
|
||||
|
||||
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") {
|
||||
return Gemini25FlashLitePreviewInputAudioPrice
|
||||
} else if strings.HasPrefix(modelName, "gemini-2.5-flash-preview") {
|
||||
return Gemini25FlashPreviewInputAudioPrice
|
||||
} else if strings.HasPrefix(modelName, "gemini-2.5-flash") {
|
||||
return Gemini25FlashProductionInputAudioPrice
|
||||
} else if strings.HasPrefix(modelName, "gemini-2.0-flash") {
|
||||
return Gemini20FlashInputAudioPrice
|
||||
} else if strings.HasPrefix(modelName, "gemini-robotics-er-1.5") {
|
||||
return GeminiRoboticsER15InputAudioPrice
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func GetGPTImage1PriceOnceCall(quality string, size string) float64 {
|
||||
prices := map[string]map[string]float64{
|
||||
"low": {
|
||||
@@ -108,3 +92,33 @@ func GetGPTImage1PriceOnceCall(quality string, size string) float64 {
|
||||
|
||||
return GPTImage1High1024x1024
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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
|
||||
} else if strings.HasPrefix(modelName, "gemini-2.5-flash-preview-lite") {
|
||||
return Gemini25FlashLitePreviewInputAudioPrice
|
||||
} else if strings.HasPrefix(modelName, "gemini-2.5-flash-preview") {
|
||||
return Gemini25FlashPreviewInputAudioPrice
|
||||
} else if strings.HasPrefix(modelName, "gemini-2.5-flash") {
|
||||
return Gemini25FlashProductionInputAudioPrice
|
||||
} else if strings.HasPrefix(modelName, "gemini-2.0-flash") {
|
||||
return Gemini20FlashInputAudioPrice
|
||||
} else if strings.HasPrefix(modelName, "gemini-robotics-er-1.5") {
|
||||
return GeminiRoboticsER15InputAudioPrice
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user