Commit 91ed4e19 by CaIon

feat: implement tiered billing expression evaluation and related functionality

- Added support for tiered billing expressions in the billing system.
- Introduced new types and functions for handling billing expressions, including caching and execution.
- Updated existing billing logic to accommodate tiered billing scenarios.
- Enhanced request handling to support incoming billing expression requests.
- Added tests for tiered billing functionality to ensure correctness.
parent a4fd2246
...@@ -29,3 +29,6 @@ data/ ...@@ -29,3 +29,6 @@ data/
.gomodcache/ .gomodcache/
.gocache-temp .gocache-temp
.gopath .gopath
token_estimator_test.go
skills-lock.json
\ No newline at end of file
...@@ -75,6 +75,7 @@ require ( ...@@ -75,6 +75,7 @@ require (
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/expr-lang/expr v1.17.8 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect github.com/gin-contrib/sse v0.1.0 // indirect
......
...@@ -68,6 +68,8 @@ github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZ ...@@ -68,6 +68,8 @@ github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZ
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/expr-lang/expr v1.17.8 h1:W1loDTT+0PQf5YteHSTpju2qfUfNoBt4yw9+wOEU9VM=
github.com/expr-lang/expr v1.17.8/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
......
...@@ -7,6 +7,7 @@ import ( ...@@ -7,6 +7,7 @@ import (
"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/setting" "github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/billing_setting"
"github.com/QuantumNous/new-api/setting/config" "github.com/QuantumNous/new-api/setting/config"
"github.com/QuantumNous/new-api/setting/operation_setting" "github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/QuantumNous/new-api/setting/performance_setting" "github.com/QuantumNous/new-api/setting/performance_setting"
...@@ -121,6 +122,8 @@ func InitOptionMap() { ...@@ -121,6 +122,8 @@ func InitOptionMap() {
common.OptionMap["UserUsableGroups"] = setting.UserUsableGroups2JSONString() common.OptionMap["UserUsableGroups"] = setting.UserUsableGroups2JSONString()
common.OptionMap["CompletionRatio"] = ratio_setting.CompletionRatio2JSONString() common.OptionMap["CompletionRatio"] = ratio_setting.CompletionRatio2JSONString()
common.OptionMap["ImageRatio"] = ratio_setting.ImageRatio2JSONString() common.OptionMap["ImageRatio"] = ratio_setting.ImageRatio2JSONString()
common.OptionMap["ModelBillingMode"] = billing_setting.BillingMode2JSONString()
common.OptionMap["ModelBillingExpr"] = billing_setting.BillingExpr2JSONString()
common.OptionMap["AudioRatio"] = ratio_setting.AudioRatio2JSONString() common.OptionMap["AudioRatio"] = ratio_setting.AudioRatio2JSONString()
common.OptionMap["AudioCompletionRatio"] = ratio_setting.AudioCompletionRatio2JSONString() common.OptionMap["AudioCompletionRatio"] = ratio_setting.AudioCompletionRatio2JSONString()
common.OptionMap["TopUpLink"] = common.TopUpLink common.OptionMap["TopUpLink"] = common.TopUpLink
...@@ -436,6 +439,10 @@ func updateOptionMap(key string, value string) (err error) { ...@@ -436,6 +439,10 @@ func updateOptionMap(key string, value string) (err error) {
err = ratio_setting.UpdateAudioRatioByJSONString(value) err = ratio_setting.UpdateAudioRatioByJSONString(value)
case "AudioCompletionRatio": case "AudioCompletionRatio":
err = ratio_setting.UpdateAudioCompletionRatioByJSONString(value) err = ratio_setting.UpdateAudioCompletionRatioByJSONString(value)
case "ModelBillingMode":
err = billing_setting.UpdateBillingModeByJSONString(value)
case "ModelBillingExpr":
err = billing_setting.UpdateBillingExprByJSONString(value)
case "TopUpLink": case "TopUpLink":
common.TopUpLink = value common.TopUpLink = value
//case "ChatLink": //case "ChatLink":
......
package billingexpr
import (
"fmt"
"math"
"sync"
"github.com/expr-lang/expr"
"github.com/expr-lang/expr/vm"
)
const maxCacheSize = 256
var (
cacheMu sync.RWMutex
cache = make(map[string]*vm.Program, 64)
)
// compileEnvPrototype is the type-checking prototype used at compile time.
// It declares the shape of the environment that RunExpr will provide.
// The tier() function is a no-op placeholder here; the real one with
// side-channel tracing is injected at runtime.
var compileEnvPrototype = map[string]interface{}{
"p": float64(0),
"c": float64(0),
"cr": float64(0),
"cc": float64(0),
"cc1h": float64(0),
"prompt_tokens": float64(0),
"completion_tokens": float64(0),
"cache_read_tokens": float64(0),
"cache_create_tokens": float64(0),
"cache_create_1h_tokens": float64(0),
"tier": func(string, float64) float64 { return 0 },
"header": func(string) string { return "" },
"param": func(string) interface{} { return nil },
"has": func(interface{}, string) bool { return false },
"hour": func(string) int { return 0 },
"minute": func(string) int { return 0 },
"weekday": func(string) int { return 0 },
"month": func(string) int { return 0 },
"day": func(string) int { return 0 },
"max": math.Max,
"min": math.Min,
"abs": math.Abs,
"ceil": math.Ceil,
"floor": math.Floor,
}
// CompileFromCache compiles an expression string, using a cached program when
// available. The cache is keyed by the SHA-256 hex digest of the expression.
func CompileFromCache(exprStr string) (*vm.Program, error) {
return compileFromCacheByHash(exprStr, ExprHashString(exprStr))
}
// CompileFromCacheByHash is like CompileFromCache but accepts a pre-computed
// hash, useful when the caller already has the BillingSnapshot.ExprHash.
func CompileFromCacheByHash(exprStr, hash string) (*vm.Program, error) {
return compileFromCacheByHash(exprStr, hash)
}
func compileFromCacheByHash(exprStr, hash string) (*vm.Program, error) {
cacheMu.RLock()
if prog, ok := cache[hash]; ok {
cacheMu.RUnlock()
return prog, nil
}
cacheMu.RUnlock()
prog, err := expr.Compile(exprStr, expr.Env(compileEnvPrototype), expr.AsFloat64())
if err != nil {
return nil, fmt.Errorf("expr compile error: %w", err)
}
cacheMu.Lock()
if len(cache) >= maxCacheSize {
cache = make(map[string]*vm.Program, 64)
}
cache[hash] = prog
cacheMu.Unlock()
return prog, nil
}
// InvalidateCache clears the compiled-expression cache.
// Called when billing rules are updated.
func InvalidateCache() {
cacheMu.Lock()
cache = make(map[string]*vm.Program, 64)
cacheMu.Unlock()
}
package billingexpr
import "math"
// QuotaRound converts a float64 quota value to int using half-away-from-zero
// rounding. Every tiered billing path (pre-consume, settlement, breakdown
// validation, log fields) MUST use this function to avoid +-1 discrepancies.
func QuotaRound(f float64) int {
return int(math.Round(f))
}
package billingexpr
import (
"fmt"
"math"
"strings"
"time"
"github.com/expr-lang/expr"
"github.com/expr-lang/expr/vm"
"github.com/tidwall/gjson"
)
// RunExpr compiles (with cache) and executes an expression string.
// The environment exposes:
// - p, c — prompt / completion tokens
// - cr, cc, cc1h — cache read / creation / creation-1h tokens
// - tier(name, value) — trace callback that records which tier matched
// - max, min, abs, ceil, floor — standard math helpers
//
// Returns the resulting float64 quota (before group ratio) and a TraceResult
// with side-channel info captured by tier() during execution.
func RunExpr(exprStr string, params TokenParams) (float64, TraceResult, error) {
return RunExprWithRequest(exprStr, params, RequestInput{})
}
func RunExprWithRequest(exprStr string, params TokenParams, request RequestInput) (float64, TraceResult, error) {
prog, err := CompileFromCache(exprStr)
if err != nil {
return 0, TraceResult{}, err
}
return runProgram(prog, params, request)
}
// RunExprByHash is like RunExpr but accepts a pre-computed hash for the cache
// lookup, avoiding a redundant SHA-256 computation when the caller already
// holds BillingSnapshot.ExprHash.
func RunExprByHash(exprStr, hash string, params TokenParams) (float64, TraceResult, error) {
return RunExprByHashWithRequest(exprStr, hash, params, RequestInput{})
}
func RunExprByHashWithRequest(exprStr, hash string, params TokenParams, request RequestInput) (float64, TraceResult, error) {
prog, err := CompileFromCacheByHash(exprStr, hash)
if err != nil {
return 0, TraceResult{}, err
}
return runProgram(prog, params, request)
}
func runProgram(prog *vm.Program, params TokenParams, request RequestInput) (float64, TraceResult, error) {
trace := TraceResult{}
headers := normalizeHeaders(request.Headers)
env := map[string]interface{}{
"p": params.P,
"c": params.C,
"cr": params.CR,
"cc": params.CC,
"cc1h": params.CC1h,
"prompt_tokens": params.P,
"completion_tokens": params.C,
"cache_read_tokens": params.CR,
"cache_create_tokens": params.CC,
"cache_create_1h_tokens": params.CC1h,
"tier": func(name string, value float64) float64 {
trace.MatchedTier = name
trace.Cost = value
return value
},
"header": func(key string) string {
return headers[strings.ToLower(strings.TrimSpace(key))]
},
"param": func(path string) interface{} {
path = strings.TrimSpace(path)
if path == "" || len(request.Body) == 0 {
return nil
}
result := gjson.GetBytes(request.Body, path)
if !result.Exists() {
return nil
}
return result.Value()
},
"has": func(source interface{}, substr string) bool {
if source == nil || substr == "" {
return false
}
return strings.Contains(fmt.Sprint(source), substr)
},
"hour": func(tz string) int { return timeInZone(tz).Hour() },
"minute": func(tz string) int { return timeInZone(tz).Minute() },
"weekday": func(tz string) int { return int(timeInZone(tz).Weekday()) },
"month": func(tz string) int { return int(timeInZone(tz).Month()) },
"day": func(tz string) int { return timeInZone(tz).Day() },
"max": math.Max,
"min": math.Min,
"abs": math.Abs,
"ceil": math.Ceil,
"floor": math.Floor,
}
out, err := expr.Run(prog, env)
if err != nil {
return 0, trace, fmt.Errorf("expr run error: %w", err)
}
f, ok := out.(float64)
if !ok {
return 0, trace, fmt.Errorf("expr result is %T, want float64", out)
}
return f, trace, nil
}
func timeInZone(tz string) time.Time {
tz = strings.TrimSpace(tz)
if tz == "" {
return time.Now().UTC()
}
loc, err := time.LoadLocation(tz)
if err != nil {
return time.Now().UTC()
}
return time.Now().In(loc)
}
func normalizeHeaders(headers map[string]string) map[string]string {
if len(headers) == 0 {
return map[string]string{}
}
normalized := make(map[string]string, len(headers))
for key, value := range headers {
k := strings.ToLower(strings.TrimSpace(key))
v := strings.TrimSpace(value)
if k == "" || v == "" {
continue
}
normalized[k] = v
}
return normalized
}
package billingexpr
// ComputeTieredQuota runs the Expr from a frozen BillingSnapshot against
// actual token counts and returns the settlement result.
func ComputeTieredQuota(snap *BillingSnapshot, params TokenParams) (TieredResult, error) {
return ComputeTieredQuotaWithRequest(snap, params, RequestInput{})
}
func ComputeTieredQuotaWithRequest(snap *BillingSnapshot, params TokenParams, request RequestInput) (TieredResult, error) {
cost, trace, err := RunExprByHashWithRequest(snap.ExprString, snap.ExprHash, params, request)
if err != nil {
return TieredResult{}, err
}
afterGroup := QuotaRound(cost * snap.GroupRatio)
crossed := trace.MatchedTier != snap.EstimatedTier
return TieredResult{
ActualQuotaBeforeGroup: cost,
ActualQuotaAfterGroup: afterGroup,
MatchedTier: trace.MatchedTier,
CrossedTier: crossed,
}, nil
}
package billingexpr
import (
"crypto/sha256"
"fmt"
)
type RequestInput struct {
Headers map[string]string
Body []byte
}
// TokenParams holds all token dimensions passed into an Expr evaluation.
// Fields beyond P and C are optional — when absent they default to 0,
// which means cache-unaware expressions keep working unchanged.
type TokenParams struct {
P float64 // prompt tokens
C float64 // completion tokens
CR float64 // cache read (hit) tokens
CC float64 // cache creation tokens (5-min TTL for Claude, generic for others)
CC1h float64 // cache creation tokens — 1-hour TTL (Claude only)
}
// TraceResult holds side-channel info captured by the tier() function
// during Expr execution. This replaces the old Breakdown mechanism —
// the Expr itself is the single source of truth for billing logic.
type TraceResult struct {
MatchedTier string `json:"matched_tier"`
Cost float64 `json:"cost"`
}
// BillingSnapshot captures the billing rule state frozen at pre-consume time.
// It is fully serializable and contains no compiled program pointers.
type BillingSnapshot struct {
BillingMode string `json:"billing_mode"`
ModelName string `json:"model_name"`
ExprString string `json:"expr_string"`
ExprHash string `json:"expr_hash"`
GroupRatio float64 `json:"group_ratio"`
EstimatedPromptTokens int `json:"estimated_prompt_tokens"`
EstimatedCompletionTokens int `json:"estimated_completion_tokens"`
EstimatedQuotaBeforeGroup float64 `json:"estimated_quota_before_group"`
EstimatedQuotaAfterGroup int `json:"estimated_quota_after_group"`
EstimatedTier string `json:"estimated_tier"`
}
// TieredResult holds everything needed after running tiered settlement.
type TieredResult struct {
ActualQuotaBeforeGroup float64 `json:"actual_quota_before_group"`
ActualQuotaAfterGroup int `json:"actual_quota_after_group"`
MatchedTier string `json:"matched_tier"`
CrossedTier bool `json:"crossed_tier"`
}
// ExprHashString returns the SHA-256 hex digest of an expression string.
func ExprHashString(expr string) string {
h := sha256.Sum256([]byte(expr))
return fmt.Sprintf("%x", h)
}
...@@ -46,7 +46,7 @@ func AudioHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type ...@@ -46,7 +46,7 @@ func AudioHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type
resp, err := adaptor.DoRequest(c, info, ioReader) resp, err := adaptor.DoRequest(c, info, ioReader)
if err != nil { if err != nil {
return types.NewError(err, types.ErrorCodeDoRequestFailed) return types.NewOpenAIError(err, types.ErrorCodeDoRequestFailed, http.StatusInternalServerError)
} }
statusCodeMappingStr := c.GetString("status_code_mapping") statusCodeMappingStr := c.GetString("status_code_mapping")
......
...@@ -2,6 +2,7 @@ package relay ...@@ -2,6 +2,7 @@ package relay
import ( import (
"bytes" "bytes"
"io"
"net/http" "net/http"
"strings" "strings"
...@@ -124,8 +125,10 @@ func chatCompletionsViaResponses(c *gin.Context, info *relaycommon.RelayInfo, ad ...@@ -124,8 +125,10 @@ func chatCompletionsViaResponses(c *gin.Context, info *relaycommon.RelayInfo, ad
return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
} }
var requestBody io.Reader = bytes.NewBuffer(jsonData)
var httpResp *http.Response var httpResp *http.Response
resp, err := adaptor.DoRequest(c, info, bytes.NewBuffer(jsonData)) resp, err := adaptor.DoRequest(c, info, requestBody)
if err != nil { if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeDoRequestFailed, http.StatusInternalServerError) return nil, types.NewOpenAIError(err, types.ErrorCodeDoRequestFailed, http.StatusInternalServerError)
} }
......
...@@ -18,4 +18,7 @@ type BillingSettler interface { ...@@ -18,4 +18,7 @@ type BillingSettler interface {
// GetPreConsumedQuota 返回实际预扣的额度值(信任用户可能为 0)。 // GetPreConsumedQuota 返回实际预扣的额度值(信任用户可能为 0)。
GetPreConsumedQuota() int GetPreConsumedQuota() int
// Reserve 将预扣额度补到目标值;若目标值不高于当前预扣额度则不做任何事。
Reserve(targetQuota int) error
} }
...@@ -10,6 +10,7 @@ import ( ...@@ -10,6 +10,7 @@ import (
"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/pkg/billingexpr"
relayconstant "github.com/QuantumNous/new-api/relay/constant" relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/QuantumNous/new-api/setting/model_setting" "github.com/QuantumNous/new-api/setting/model_setting"
"github.com/QuantumNous/new-api/types" "github.com/QuantumNous/new-api/types"
...@@ -152,6 +153,11 @@ type RelayInfo struct { ...@@ -152,6 +153,11 @@ type RelayInfo struct {
PriceData types.PriceData PriceData types.PriceData
// TieredBillingSnapshot is a frozen snapshot of tiered billing rules
// captured at pre-consume time. Non-nil only when billing mode is "tiered_expr".
TieredBillingSnapshot *billingexpr.BillingSnapshot
BillingRequestInput *billingexpr.RequestInput
Request dto.Request Request dto.Request
// RequestConversionChain records request format conversions in order, e.g. // RequestConversionChain records request format conversions in order, e.g.
......
...@@ -13,6 +13,7 @@ import ( ...@@ -13,6 +13,7 @@ import (
"github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/billingexpr"
relaycommon "github.com/QuantumNous/new-api/relay/common" relaycommon "github.com/QuantumNous/new-api/relay/common"
relayconstant "github.com/QuantumNous/new-api/relay/constant" relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/QuantumNous/new-api/relay/helper" "github.com/QuantumNous/new-api/relay/helper"
...@@ -236,6 +237,18 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage ...@@ -236,6 +237,18 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
service.ObserveChannelAffinityUsageCacheByRelayFormat(ctx, usage, relayInfo.GetFinalRequestRelayFormat()) service.ObserveChannelAffinityUsageCacheByRelayFormat(ctx, usage, relayInfo.GetFinalRequestRelayFormat())
} }
// Tiered billing early return
if ok, tieredQuota, tieredResult := service.TryTieredSettle(relayInfo, billingexpr.TokenParams{
P: float64(usage.PromptTokens),
C: float64(usage.CompletionTokens),
CR: float64(usage.PromptTokensDetails.CachedTokens),
CC: float64(usage.PromptTokensDetails.CachedCreationTokens - usage.ClaudeCacheCreation1hTokens),
CC1h: float64(usage.ClaudeCacheCreation1hTokens),
}); ok {
postConsumeQuotaTiered(ctx, relayInfo, usage, tieredQuota, tieredResult, extraContent...)
return
}
adminRejectReason := common.GetContextKeyString(ctx, constant.ContextKeyAdminRejectReason) adminRejectReason := common.GetContextKeyString(ctx, constant.ContextKeyAdminRejectReason)
useTimeSeconds := time.Now().Unix() - relayInfo.StartTime.Unix() useTimeSeconds := time.Now().Unix() - relayInfo.StartTime.Unix()
...@@ -506,3 +519,51 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage ...@@ -506,3 +519,51 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
Other: other, Other: other,
}) })
} }
func postConsumeQuotaTiered(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, quota int, tieredResult *service.TieredResultWrapper, extraContent ...string) {
_ = tieredResult // will be used for log enrichment
useTimeSeconds := time.Now().Unix() - relayInfo.StartTime.Unix()
modelName := relayInfo.OriginModelName
tokenName := ctx.GetString("token_name")
groupRatio := relayInfo.PriceData.GroupRatioInfo.GroupRatio
totalTokens := usage.PromptTokens + usage.CompletionTokens
if totalTokens == 0 {
quota = 0
extraContent = append(extraContent, "上游没有返回计费信息,无法扣费(可能是上游超时)")
logger.LogError(ctx, fmt.Sprintf("tiered billing: total tokens is 0, userId %d, channelId %d, tokenId %d, model %s, pre-consumed %d",
relayInfo.UserId, relayInfo.ChannelId, relayInfo.TokenId, modelName, relayInfo.FinalPreConsumedQuota))
} else {
if groupRatio != 0 && quota == 0 {
quota = 1
}
model.UpdateUserUsedQuotaAndRequestCount(relayInfo.UserId, quota)
model.UpdateChannelUsedQuota(relayInfo.ChannelId, quota)
}
if err := service.SettleBilling(ctx, relayInfo, quota); err != nil {
logger.LogError(ctx, "error settling tiered billing: "+err.Error())
}
logModel := modelName
logContent := strings.Join(extraContent, ", ")
other := service.GenerateTieredOtherInfo(ctx, relayInfo, tieredResult)
model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{
ChannelId: relayInfo.ChannelId,
PromptTokens: usage.PromptTokens,
CompletionTokens: usage.CompletionTokens,
ModelName: logModel,
TokenName: tokenName,
Quota: quota,
Content: logContent,
TokenId: relayInfo.TokenId,
UseTimeSeconds: int(useTimeSeconds),
IsStream: relayInfo.IsStream,
Group: relayInfo.UsingGroup,
Other: other,
})
}
...@@ -3,6 +3,7 @@ package relay ...@@ -3,6 +3,7 @@ package relay
import ( import (
"bytes" "bytes"
"fmt" "fmt"
"io"
"net/http" "net/http"
"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
...@@ -58,7 +59,7 @@ func EmbeddingHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError * ...@@ -58,7 +59,7 @@ func EmbeddingHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *
} }
logger.LogDebug(c, fmt.Sprintf("converted embedding request body: %s", string(jsonData))) logger.LogDebug(c, fmt.Sprintf("converted embedding request body: %s", string(jsonData)))
requestBody := bytes.NewBuffer(jsonData) var requestBody io.Reader = bytes.NewBuffer(jsonData)
statusCodeMappingStr := c.GetString("status_code_mapping") statusCodeMappingStr := c.GetString("status_code_mapping")
resp, err := adaptor.DoRequest(c, info, requestBody) resp, err := adaptor.DoRequest(c, info, requestBody)
if err != nil { if err != nil {
......
package helper
import (
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/pkg/billingexpr"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/gin-gonic/gin"
)
func ResolveIncomingBillingExprRequestInput(c *gin.Context, info *relaycommon.RelayInfo) (billingexpr.RequestInput, error) {
input := billingexpr.RequestInput{}
if info != nil {
input.Headers = cloneStringMap(info.RequestHeaders)
}
bodyBytes, err := readIncomingBillingExprBody(c)
if err != nil {
return billingexpr.RequestInput{}, err
}
input.Body = bodyBytes
return input, nil
}
func readIncomingBillingExprBody(c *gin.Context) ([]byte, error) {
if c == nil || c.Request == nil || !isJSONContentType(c.Request.Header.Get("Content-Type")) {
return nil, nil
}
storage, err := common.GetBodyStorage(c)
if err != nil {
return nil, err
}
return storage.Bytes()
}
func isJSONContentType(contentType string) bool {
contentType = strings.ToLower(strings.TrimSpace(contentType))
return strings.HasPrefix(contentType, "application/json")
}
func cloneStringMap(src map[string]string) map[string]string {
if len(src) == 0 {
return map[string]string{}
}
dst := make(map[string]string, len(src))
for key, value := range src {
if strings.TrimSpace(key) == "" {
continue
}
dst[key] = value
}
return dst
}
package helper
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/QuantumNous/new-api/common"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func TestResolveIncomingBillingExprRequestInput(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
ctx.Request.Header.Set("Content-Type", "application/json")
body := []byte(`{"service_tier":"fast"}`)
ctx.Request.Body = io.NopCloser(bytes.NewReader(body))
ctx.Set(common.KeyRequestBody, body)
info := &relaycommon.RelayInfo{
RequestHeaders: map[string]string{"Content-Type": "application/json"},
}
input, err := ResolveIncomingBillingExprRequestInput(ctx, info)
require.NoError(t, err)
require.Equal(t, body, input.Body)
require.Equal(t, "application/json", input.Headers["Content-Type"])
}
...@@ -5,7 +5,9 @@ import ( ...@@ -5,7 +5,9 @@ import (
"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/pkg/billingexpr"
relaycommon "github.com/QuantumNous/new-api/relay/common" relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/setting/billing_setting"
"github.com/QuantumNous/new-api/setting/operation_setting" "github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/QuantumNous/new-api/setting/ratio_setting" "github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/QuantumNous/new-api/types" "github.com/QuantumNous/new-api/types"
...@@ -50,6 +52,11 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens ...@@ -50,6 +52,11 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
groupRatioInfo := HandleGroupRatio(c, info) groupRatioInfo := HandleGroupRatio(c, info)
// Check if this model uses tiered_expr billing
if billing_setting.GetBillingMode(info.OriginModelName) == billing_setting.BillingModeTieredExpr {
return modelPriceHelperTiered(c, info, promptTokens, meta, groupRatioInfo)
}
var preConsumedQuota int var preConsumedQuota int
var modelRatio float64 var modelRatio float64
var completionRatio float64 var completionRatio float64
...@@ -195,5 +202,73 @@ func ContainPriceOrRatio(modelName string) bool { ...@@ -195,5 +202,73 @@ func ContainPriceOrRatio(modelName string) bool {
if ok { if ok {
return true return true
} }
if billing_setting.GetBillingMode(modelName) == billing_setting.BillingModeTieredExpr {
_, ok = billing_setting.GetBillingExpr(modelName)
return ok
}
return false return false
} }
func modelPriceHelperTiered(c *gin.Context, info *relaycommon.RelayInfo, promptTokens int, meta *types.TokenCountMeta, groupRatioInfo types.GroupRatioInfo) (types.PriceData, error) {
exprStr, ok := billing_setting.GetBillingExpr(info.OriginModelName)
if !ok {
return types.PriceData{}, fmt.Errorf("model %s is configured as tiered_expr but has no billing expression", info.OriginModelName)
}
estimatedCompletionTokens := 0
if meta.MaxTokens != 0 {
estimatedCompletionTokens = meta.MaxTokens
}
requestInput, err := ResolveIncomingBillingExprRequestInput(c, info)
if err != nil {
return types.PriceData{}, err
}
rawQuota, trace, err := billingexpr.RunExprWithRequest(exprStr, billingexpr.TokenParams{
P: float64(promptTokens),
C: float64(estimatedCompletionTokens),
}, requestInput)
if err != nil {
return types.PriceData{}, fmt.Errorf("model %s tiered expr run failed: %w", info.OriginModelName, err)
}
preConsumedQuota := billingexpr.QuotaRound(rawQuota * groupRatioInfo.GroupRatio)
freeModel := false
if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume {
if groupRatioInfo.GroupRatio == 0 || rawQuota == 0 {
preConsumedQuota = 0
freeModel = true
}
}
exprHash := billingexpr.ExprHashString(exprStr)
snapshot := &billingexpr.BillingSnapshot{
BillingMode: billing_setting.BillingModeTieredExpr,
ModelName: info.OriginModelName,
ExprString: exprStr,
ExprHash: exprHash,
GroupRatio: groupRatioInfo.GroupRatio,
EstimatedPromptTokens: promptTokens,
EstimatedCompletionTokens: estimatedCompletionTokens,
EstimatedQuotaBeforeGroup: rawQuota,
EstimatedQuotaAfterGroup: preConsumedQuota,
EstimatedTier: trace.MatchedTier,
}
info.TieredBillingSnapshot = snapshot
info.BillingRequestInput = &requestInput
priceData := types.PriceData{
FreeModel: freeModel,
GroupRatioInfo: groupRatioInfo,
QuotaToPreConsume: preConsumedQuota,
}
if common.DebugEnabled {
println(fmt.Sprintf("model_price_helper_tiered result: model=%s preConsume=%d rawQuota=%.2f groupRatio=%.2f tier=%s", info.OriginModelName, preConsumedQuota, rawQuota, groupRatioInfo.GroupRatio, trace.MatchedTier))
}
info.PriceData = priceData
return priceData, nil
}
...@@ -27,6 +27,8 @@ type BillingSession struct { ...@@ -27,6 +27,8 @@ type BillingSession struct {
funding FundingSource funding FundingSource
preConsumedQuota int // 实际预扣额度(信任用户可能为 0) preConsumedQuota int // 实际预扣额度(信任用户可能为 0)
tokenConsumed int // 令牌额度实际扣减量 tokenConsumed int // 令牌额度实际扣减量
extraReserved int // 发送前补充预扣的额度(订阅退款时需要单独回滚)
trusted bool // 是否命中信任额度旁路
fundingSettled bool // funding.Settle 已成功,资金来源已提交 fundingSettled bool // funding.Settle 已成功,资金来源已提交
settled bool // Settle 全部完成(资金 + 令牌) settled bool // Settle 全部完成(资金 + 令牌)
refunded bool // Refund 已调用 refunded bool // Refund 已调用
...@@ -97,6 +99,8 @@ func (s *BillingSession) Refund(c *gin.Context) { ...@@ -97,6 +99,8 @@ func (s *BillingSession) Refund(c *gin.Context) {
tokenKey := s.relayInfo.TokenKey tokenKey := s.relayInfo.TokenKey
isPlayground := s.relayInfo.IsPlayground isPlayground := s.relayInfo.IsPlayground
tokenConsumed := s.tokenConsumed tokenConsumed := s.tokenConsumed
extraReserved := s.extraReserved
subscriptionId := s.relayInfo.SubscriptionId
funding := s.funding funding := s.funding
gopool.Go(func() { gopool.Go(func() {
...@@ -104,6 +108,11 @@ func (s *BillingSession) Refund(c *gin.Context) { ...@@ -104,6 +108,11 @@ func (s *BillingSession) Refund(c *gin.Context) {
if err := funding.Refund(); err != nil { if err := funding.Refund(); err != nil {
common.SysLog("error refunding billing source: " + err.Error()) common.SysLog("error refunding billing source: " + err.Error())
} }
if extraReserved > 0 && funding.Source() == BillingSourceSubscription && subscriptionId > 0 {
if err := model.PostConsumeUserSubscriptionDelta(subscriptionId, -int64(extraReserved)); err != nil {
common.SysLog("error refunding subscription extra reserved quota: " + err.Error())
}
}
// 2) 退还令牌额度 // 2) 退还令牌额度
if tokenConsumed > 0 && !isPlayground { if tokenConsumed > 0 && !isPlayground {
if err := model.IncreaseTokenQuota(tokenId, tokenKey, tokenConsumed); err != nil { if err := model.IncreaseTokenQuota(tokenId, tokenKey, tokenConsumed); err != nil {
...@@ -140,6 +149,34 @@ func (s *BillingSession) GetPreConsumedQuota() int { ...@@ -140,6 +149,34 @@ func (s *BillingSession) GetPreConsumedQuota() int {
return s.preConsumedQuota return s.preConsumedQuota
} }
func (s *BillingSession) Reserve(targetQuota int) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.settled || s.refunded || s.trusted || targetQuota <= s.preConsumedQuota {
return nil
}
delta := targetQuota - s.preConsumedQuota
if delta <= 0 {
return nil
}
if err := s.reserveFunding(delta); err != nil {
return err
}
if err := s.reserveToken(delta); err != nil {
s.rollbackFundingReserve(delta)
return err
}
s.preConsumedQuota += delta
s.tokenConsumed += delta
s.extraReserved += delta
s.syncRelayInfo()
return nil
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// PreConsume — 统一预扣费入口(含信任额度旁路) // PreConsume — 统一预扣费入口(含信任额度旁路)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
...@@ -151,6 +188,7 @@ func (s *BillingSession) preConsume(c *gin.Context, quota int) *types.NewAPIErro ...@@ -151,6 +188,7 @@ func (s *BillingSession) preConsume(c *gin.Context, quota int) *types.NewAPIErro
// ---- 信任额度旁路 ---- // ---- 信任额度旁路 ----
if s.shouldTrust(c) { if s.shouldTrust(c) {
s.trusted = true
effectiveQuota = 0 effectiveQuota = 0
logger.LogInfo(c, fmt.Sprintf("用户 %d 额度充足, 信任且不需要预扣费 (funding=%s)", s.relayInfo.UserId, s.funding.Source())) logger.LogInfo(c, fmt.Sprintf("用户 %d 额度充足, 信任且不需要预扣费 (funding=%s)", s.relayInfo.UserId, s.funding.Source()))
} else if effectiveQuota > 0 { } else if effectiveQuota > 0 {
...@@ -191,6 +229,55 @@ func (s *BillingSession) preConsume(c *gin.Context, quota int) *types.NewAPIErro ...@@ -191,6 +229,55 @@ func (s *BillingSession) preConsume(c *gin.Context, quota int) *types.NewAPIErro
return nil return nil
} }
func (s *BillingSession) reserveFunding(delta int) error {
switch funding := s.funding.(type) {
case *WalletFunding:
if err := model.DecreaseUserQuota(funding.userId, delta); err != nil {
return types.NewError(err, types.ErrorCodeUpdateDataError, types.ErrOptionWithSkipRetry())
}
funding.consumed += delta
return nil
case *SubscriptionFunding:
if err := model.PostConsumeUserSubscriptionDelta(funding.subscriptionId, int64(delta)); err != nil {
return types.NewErrorWithStatusCode(
fmt.Errorf("订阅额度不足或未配置订阅: %s", err.Error()),
types.ErrorCodeInsufficientUserQuota,
http.StatusForbidden,
types.ErrOptionWithSkipRetry(),
types.ErrOptionWithNoRecordErrorLog(),
)
}
return nil
default:
return types.NewError(fmt.Errorf("unsupported funding source: %s", s.funding.Source()), types.ErrorCodeUpdateDataError, types.ErrOptionWithSkipRetry())
}
}
func (s *BillingSession) rollbackFundingReserve(delta int) {
switch funding := s.funding.(type) {
case *WalletFunding:
if err := model.IncreaseUserQuota(funding.userId, delta, false); err != nil {
common.SysLog("error rolling back wallet funding reserve: " + err.Error())
} else {
funding.consumed -= delta
}
case *SubscriptionFunding:
if err := model.PostConsumeUserSubscriptionDelta(funding.subscriptionId, -int64(delta)); err != nil {
common.SysLog("error rolling back subscription funding reserve: " + err.Error())
}
}
}
func (s *BillingSession) reserveToken(delta int) error {
if delta <= 0 || s.relayInfo.IsPlayground {
return nil
}
if err := PreConsumeTokenQuota(s.relayInfo, delta); err != nil {
return types.NewErrorWithStatusCode(err, types.ErrorCodePreConsumeTokenQuotaFailed, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
}
return nil
}
// shouldTrust 统一信任额度检查,适用于钱包和订阅。 // shouldTrust 统一信任额度检查,适用于钱包和订阅。
func (s *BillingSession) shouldTrust(c *gin.Context) bool { func (s *BillingSession) shouldTrust(c *gin.Context) bool {
// 异步任务(ForcePreConsume=true)必须预扣全额,不允许信任旁路 // 异步任务(ForcePreConsume=true)必须预扣全额,不允许信任旁路
...@@ -235,10 +322,10 @@ func (s *BillingSession) syncRelayInfo() { ...@@ -235,10 +322,10 @@ func (s *BillingSession) syncRelayInfo() {
if sub, ok := s.funding.(*SubscriptionFunding); ok { if sub, ok := s.funding.(*SubscriptionFunding); ok {
info.SubscriptionId = sub.subscriptionId info.SubscriptionId = sub.subscriptionId
info.SubscriptionPreConsumed = sub.preConsumed info.SubscriptionPreConsumed = sub.preConsumed + int64(s.extraReserved)
info.SubscriptionPostDelta = 0 info.SubscriptionPostDelta = 0
info.SubscriptionAmountTotal = sub.AmountTotal info.SubscriptionAmountTotal = sub.AmountTotal
info.SubscriptionAmountUsedAfterPreConsume = sub.AmountUsedAfter info.SubscriptionAmountUsedAfterPreConsume = sub.AmountUsedAfter + int64(s.extraReserved)
info.SubscriptionPlanId = sub.PlanId info.SubscriptionPlanId = sub.PlanId
info.SubscriptionPlanTitle = sub.PlanTitle info.SubscriptionPlanTitle = sub.PlanTitle
} else { } else {
......
...@@ -6,6 +6,7 @@ import ( ...@@ -6,6 +6,7 @@ import (
"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/pkg/billingexpr"
relaycommon "github.com/QuantumNous/new-api/relay/common" relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/types" "github.com/QuantumNous/new-api/types"
...@@ -214,3 +215,42 @@ func GenerateMjOtherInfo(relayInfo *relaycommon.RelayInfo, priceData types.Price ...@@ -214,3 +215,42 @@ func GenerateMjOtherInfo(relayInfo *relaycommon.RelayInfo, priceData types.Price
appendRequestPath(nil, relayInfo, other) appendRequestPath(nil, relayInfo, other)
return other return other
} }
func GenerateTieredOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, result *billingexpr.TieredResult) map[string]interface{} {
other := make(map[string]interface{})
other["billing_mode"] = "tiered_expr"
snap := relayInfo.TieredBillingSnapshot
if snap != nil {
other["group_ratio"] = snap.GroupRatio
other["expr_hash"] = snap.ExprHash
other["estimated_prompt_tokens"] = snap.EstimatedPromptTokens
other["estimated_completion_tokens"] = snap.EstimatedCompletionTokens
other["estimated_quota_before_group"] = snap.EstimatedQuotaBeforeGroup
other["estimated_quota_after_group"] = snap.EstimatedQuotaAfterGroup
other["estimated_tier"] = snap.EstimatedTier
}
if result != nil {
other["actual_quota_before_group"] = result.ActualQuotaBeforeGroup
other["actual_quota_after_group"] = result.ActualQuotaAfterGroup
other["matched_tier"] = result.MatchedTier
other["crossed_tier"] = result.CrossedTier
}
other["frt"] = float64(relayInfo.FirstResponseTime.UnixMilli() - relayInfo.StartTime.UnixMilli())
if relayInfo.IsModelMapped {
other["is_model_mapped"] = true
other["upstream_model_name"] = relayInfo.UpstreamModelName
}
adminInfo := make(map[string]interface{})
adminInfo["use_channel"] = ctx.GetStringSlice("use_channel")
AppendChannelAffinityAdminInfo(ctx, adminInfo)
other["admin_info"] = adminInfo
appendRequestPath(ctx, relayInfo, other)
appendRequestConversionChain(relayInfo, other)
appendBillingInfo(relayInfo, other)
return other
}
...@@ -13,6 +13,7 @@ import ( ...@@ -13,6 +13,7 @@ import (
"github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/billingexpr"
relaycommon "github.com/QuantumNous/new-api/relay/common" relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/setting/ratio_setting" "github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/QuantumNous/new-api/setting/system_setting" "github.com/QuantumNous/new-api/setting/system_setting"
...@@ -157,6 +158,15 @@ func PreWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usag ...@@ -157,6 +158,15 @@ func PreWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usag
func PostWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, modelName string, func PostWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, modelName string,
usage *dto.RealtimeUsage, extraContent string) { usage *dto.RealtimeUsage, extraContent string) {
// Tiered billing early return
if ok, tieredQuota, tieredResult := TryTieredSettle(relayInfo, billingexpr.TokenParams{
P: float64(usage.InputTokens),
C: float64(usage.OutputTokens),
}); ok {
postConsumeQuotaTieredService(ctx, relayInfo, modelName, usage.InputTokens, usage.OutputTokens, usage.TotalTokens, tieredQuota, tieredResult, extraContent)
return
}
useTimeSeconds := time.Now().Unix() - relayInfo.StartTime.Unix() useTimeSeconds := time.Now().Unix() - relayInfo.StartTime.Unix()
textInputTokens := usage.InputTokenDetails.TextTokens textInputTokens := usage.InputTokenDetails.TextTokens
textOutTokens := usage.OutputTokenDetails.TextTokens textOutTokens := usage.OutputTokenDetails.TextTokens
...@@ -240,6 +250,18 @@ func PostClaudeConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, ...@@ -240,6 +250,18 @@ func PostClaudeConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
ObserveChannelAffinityUsageCacheByRelayFormat(ctx, usage, relayInfo.GetFinalRequestRelayFormat()) ObserveChannelAffinityUsageCacheByRelayFormat(ctx, usage, relayInfo.GetFinalRequestRelayFormat())
} }
// Tiered billing early return
if ok, tieredQuota, tieredResult := TryTieredSettle(relayInfo, billingexpr.TokenParams{
P: float64(usage.PromptTokens),
C: float64(usage.CompletionTokens),
CR: float64(usage.PromptTokensDetails.CachedTokens),
CC: float64(usage.PromptTokensDetails.CachedCreationTokens - usage.ClaudeCacheCreation1hTokens),
CC1h: float64(usage.ClaudeCacheCreation1hTokens),
}); ok {
postConsumeQuotaTieredService(ctx, relayInfo, relayInfo.OriginModelName, usage.PromptTokens, usage.CompletionTokens, usage.PromptTokens+usage.CompletionTokens, tieredQuota, tieredResult, "")
return
}
useTimeSeconds := time.Now().Unix() - relayInfo.StartTime.Unix() useTimeSeconds := time.Now().Unix() - relayInfo.StartTime.Unix()
promptTokens := usage.PromptTokens promptTokens := usage.PromptTokens
completionTokens := usage.CompletionTokens completionTokens := usage.CompletionTokens
...@@ -360,6 +382,16 @@ func CalcOpenRouterCacheCreateTokens(usage dto.Usage, priceData types.PriceData) ...@@ -360,6 +382,16 @@ func CalcOpenRouterCacheCreateTokens(usage dto.Usage, priceData types.PriceData)
func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, extraContent string) { func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, extraContent string) {
// Tiered billing early return
if ok, tieredQuota, tieredResult := TryTieredSettle(relayInfo, billingexpr.TokenParams{
P: float64(usage.PromptTokens),
C: float64(usage.CompletionTokens),
CR: float64(usage.PromptTokensDetails.CachedTokens),
}); ok {
postConsumeQuotaTieredService(ctx, relayInfo, relayInfo.OriginModelName, usage.PromptTokens, usage.CompletionTokens, usage.TotalTokens, tieredQuota, tieredResult, extraContent)
return
}
useTimeSeconds := time.Now().Unix() - relayInfo.StartTime.Unix() useTimeSeconds := time.Now().Unix() - relayInfo.StartTime.Unix()
textInputTokens := usage.PromptTokensDetails.TextTokens textInputTokens := usage.PromptTokensDetails.TextTokens
textOutTokens := usage.CompletionTokenDetails.TextTokens textOutTokens := usage.CompletionTokenDetails.TextTokens
...@@ -607,3 +639,50 @@ func checkAndSendSubscriptionQuotaNotify(relayInfo *relaycommon.RelayInfo) { ...@@ -607,3 +639,50 @@ func checkAndSendSubscriptionQuotaNotify(relayInfo *relaycommon.RelayInfo) {
} }
}) })
} }
func postConsumeQuotaTieredService(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, modelName string,
promptTokens, completionTokens, totalTokens, quota int, tieredResult *TieredResultWrapper, extraContent string) {
useTimeSeconds := time.Now().Unix() - relayInfo.StartTime.Unix()
tokenName := ctx.GetString("token_name")
groupRatio := relayInfo.PriceData.GroupRatioInfo.GroupRatio
var logContent string
if totalTokens == 0 {
quota = 0
logContent = "上游没有返回计费信息(可能是上游超时)"
logger.LogError(ctx, fmt.Sprintf("tiered billing: total tokens is 0, userId %d, channelId %d, tokenId %d, model %s, pre-consumed %d",
relayInfo.UserId, relayInfo.ChannelId, relayInfo.TokenId, modelName, relayInfo.FinalPreConsumedQuota))
} else {
if groupRatio != 0 && quota == 0 {
quota = 1
}
model.UpdateUserUsedQuotaAndRequestCount(relayInfo.UserId, quota)
model.UpdateChannelUsedQuota(relayInfo.ChannelId, quota)
}
if err := SettleBilling(ctx, relayInfo, quota); err != nil {
logger.LogError(ctx, "error settling tiered billing: "+err.Error())
}
if extraContent != "" {
logContent += extraContent
}
other := GenerateTieredOtherInfo(ctx, relayInfo, tieredResult)
model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{
ChannelId: relayInfo.ChannelId,
PromptTokens: promptTokens,
CompletionTokens: completionTokens,
ModelName: modelName,
TokenName: tokenName,
Quota: quota,
Content: logContent,
TokenId: relayInfo.TokenId,
UseTimeSeconds: int(useTimeSeconds),
IsStream: relayInfo.IsStream,
Group: relayInfo.UsingGroup,
Other: other,
})
}
package service
import (
"github.com/QuantumNous/new-api/pkg/billingexpr"
relaycommon "github.com/QuantumNous/new-api/relay/common"
)
// TieredResultWrapper wraps billingexpr.TieredResult for use at the service layer.
type TieredResultWrapper = billingexpr.TieredResult
// TryTieredSettle checks if the request uses tiered_expr billing and, if so,
// computes the actual quota using the frozen BillingSnapshot. Returns:
// - ok=true, quota, result when tiered billing applies
// - ok=false, 0, nil when it doesn't (caller should fall through to existing logic)
func TryTieredSettle(relayInfo *relaycommon.RelayInfo, params billingexpr.TokenParams) (ok bool, quota int, result *billingexpr.TieredResult) {
snap := relayInfo.TieredBillingSnapshot
if snap == nil || snap.BillingMode != "tiered_expr" {
return false, 0, nil
}
requestInput := billingexpr.RequestInput{}
if relayInfo.BillingRequestInput != nil {
requestInput = *relayInfo.BillingRequestInput
}
tr, err := billingexpr.ComputeTieredQuotaWithRequest(snap, params, requestInput)
if err != nil {
quota = relayInfo.FinalPreConsumedQuota
if quota <= 0 {
quota = snap.EstimatedQuotaAfterGroup
}
return true, quota, nil
}
return true, tr.ActualQuotaAfterGroup, &tr
}
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)
)
const (
BillingModeRatio = "ratio"
BillingModeTieredExpr = "tiered_expr"
)
// ---------------------------------------------------------------------------
// Read accessors (hot path, must be fast)
// ---------------------------------------------------------------------------
func GetBillingMode(model string) string {
mu.RLock()
defer mu.RUnlock()
if mode, ok := billingModeMap[model]; ok {
return mode
}
return BillingModeRatio
}
func GetBillingExpr(model string) (string, bool) {
mu.RLock()
defer mu.RUnlock()
expr, ok := billingExprMap[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)
// ---------------------------------------------------------------------------
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 {
vectors := []billingexpr.TokenParams{
{P: 0, C: 0},
{P: 1000, C: 1000},
{P: 100000, C: 100000},
{P: 1000000, C: 1000000},
}
requests := []billingexpr.RequestInput{
{},
{
Headers: map[string]string{
"anthropic-beta": "fast-mode-2026-02-01",
},
Body: []byte(`{"service_tier":"fast","stream_options":{"include_usage":true},"messages":[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21]}`),
},
}
for _, v := range vectors {
for _, request := range requests {
result, _, err := billingexpr.RunExprWithRequest(exprStr, v, request)
if err != nil {
return fmt.Errorf("vector {p=%g, c=%g}: run failed: %w", v.P, v.C, err)
}
if result < 0 {
return fmt.Errorf("vector {p=%g, c=%g}: result %f < 0", v.P, v.C, result)
}
}
}
return nil
}
package model_setting
import (
"net/http"
"testing"
)
func TestClaudeSettingsWriteHeadersMergesConfiguredValuesIntoSingleHeader(t *testing.T) {
settings := &ClaudeSettings{
HeadersSettings: map[string]map[string][]string{
"claude-3-7-sonnet-20250219-thinking": {
"anthropic-beta": {
"token-efficient-tools-2025-02-19",
},
},
},
}
headers := http.Header{}
headers.Set("anthropic-beta", "output-128k-2025-02-19")
settings.WriteHeaders("claude-3-7-sonnet-20250219-thinking", &headers)
got := headers.Values("anthropic-beta")
if len(got) != 1 {
t.Fatalf("expected a single merged header value, got %v", got)
}
expected := "output-128k-2025-02-19,token-efficient-tools-2025-02-19"
if got[0] != expected {
t.Fatalf("expected merged header %q, got %q", expected, got[0])
}
}
func TestClaudeSettingsWriteHeadersDeduplicatesAcrossCommaSeparatedAndRepeatedValues(t *testing.T) {
settings := &ClaudeSettings{
HeadersSettings: map[string]map[string][]string{
"claude-3-7-sonnet-20250219-thinking": {
"anthropic-beta": {
"token-efficient-tools-2025-02-19",
"computer-use-2025-01-24",
},
},
},
}
headers := http.Header{}
headers.Add("anthropic-beta", "output-128k-2025-02-19, token-efficient-tools-2025-02-19")
headers.Add("anthropic-beta", "token-efficient-tools-2025-02-19")
settings.WriteHeaders("claude-3-7-sonnet-20250219-thinking", &headers)
got := headers.Values("anthropic-beta")
if len(got) != 1 {
t.Fatalf("expected duplicate values to collapse into one header, got %v", got)
}
expected := "output-128k-2025-02-19,token-efficient-tools-2025-02-19,computer-use-2025-01-24"
if got[0] != expected {
t.Fatalf("expected deduplicated merged header %q, got %q", expected, got[0])
}
}
...@@ -377,6 +377,43 @@ function renderCompactDetailSummary(summarySegments) { ...@@ -377,6 +377,43 @@ function renderCompactDetailSummary(summarySegments) {
); );
} }
function buildTieredBillingSegments(other, t) {
const segments = [
{ text: `${t('阶梯计费')}`, tone: 'primary' },
];
if (other.matched_tier) {
segments.push({
text: `${t('命中档位')}: ${other.matched_tier}`,
tone: 'secondary',
});
}
const groupRatio = other.group_ratio;
if (groupRatio !== undefined && groupRatio !== null) {
segments.push({
text: `${t('分组')} ${formatRatio(groupRatio)}x`,
tone: 'secondary',
});
}
if (other.crossed_tier) {
segments.push({
text: `${t('跨阶梯')}: ${t('是')}`,
tone: 'secondary',
});
}
if (other.actual_quota_after_group !== undefined) {
segments.push({
text: `${t('实际额度')}: ${other.actual_quota_after_group}`,
tone: 'secondary',
});
}
return { segments };
}
function getUsageLogDetailSummary(record, text, billingDisplayMode, t) { function getUsageLogDetailSummary(record, text, billingDisplayMode, t) {
const other = getLogOther(record.other); const other = getLogOther(record.other);
...@@ -414,6 +451,10 @@ function getUsageLogDetailSummary(record, text, billingDisplayMode, t) { ...@@ -414,6 +451,10 @@ function getUsageLogDetailSummary(record, text, billingDisplayMode, t) {
}; };
} }
if (other?.billing_mode === 'tiered_expr') {
return buildTieredBillingSegments(other, t);
}
return { return {
segments: other?.claude segments: other?.claude
? renderModelPriceSimple( ? renderModelPriceSimple(
......
...@@ -559,6 +559,66 @@ export const useLogsData = () => { ...@@ -559,6 +559,66 @@ export const useLogsData = () => {
value: other.reasoning_effort, value: other.reasoning_effort,
}); });
} }
if (other?.billing_mode === 'tiered_expr') {
expandDataLocal.push({
key: t('计费方式'),
value: t('阶梯计费'),
});
if (other?.group_ratio !== undefined) {
const gr = other.group_ratio;
expandDataLocal.push({
key: t('分组倍率'),
value: typeof gr === 'number' ? gr.toFixed(4) : String(gr ?? '-'),
});
}
if (other?.rule_version !== undefined) {
expandDataLocal.push({
key: t('规则版本'),
value: String(other.rule_version),
});
}
if (other?.estimated_env) {
expandDataLocal.push({
key: t('预估环境'),
value: `prompt=${other.estimated_env.prompt_tokens ?? 0}, completion=${other.estimated_env.completion_tokens ?? 0}`,
});
}
if (other?.actual_env) {
expandDataLocal.push({
key: t('实际环境'),
value: `prompt=${other.actual_env.prompt_tokens ?? 0}, completion=${other.actual_env.completion_tokens ?? 0}`,
});
}
if (other?.estimated_quota_after_group !== undefined) {
expandDataLocal.push({
key: t('预估额度'),
value: String(other.estimated_quota_after_group),
});
}
if (other?.actual_quota_after_group !== undefined) {
expandDataLocal.push({
key: t('实际额度'),
value: String(other.actual_quota_after_group),
});
}
expandDataLocal.push({
key: t('跨阶梯'),
value: other?.crossed_tier ? t('是') : t('否'),
});
if (Array.isArray(other?.breakdown) && other.breakdown.length > 0) {
const breakdownText = other.breakdown.map((item, idx) =>
`[${idx}] ${item.token_type} | tokens=${item.tokens_in_tier} | cost=${item.unit_cost} | flat=${item.flat_fee} | sub=${item.subtotal}`
).join('\n');
expandDataLocal.push({
key: t('计费明细'),
value: (
<div style={{ whiteSpace: 'pre-line', fontFamily: 'monospace', fontSize: 12 }}>
{breakdownText}
</div>
),
});
}
}
} }
if (logs[i].type === 6) { if (logs[i].type === 6) {
if (other?.task_id) { if (other?.task_id) {
......
...@@ -3256,6 +3256,20 @@ ...@@ -3256,6 +3256,20 @@
"补全价格": "Completion Price", "补全价格": "Completion Price",
"缓存读取价格": "Input Cache Read Price", "缓存读取价格": "Input Cache Read Price",
"缓存创建价格": "Input Cache Creation Price", "缓存创建价格": "Input Cache Creation Price",
"缓存创建价格-5分钟": "Cache Creation Price (5-min)",
"缓存创建价格-1小时": "Cache Creation Price (1-hour)",
"缓存创建价格(5分钟)": "Cache Creation Price (5-min)",
"缓存创建价格(1小时)": "Cache Creation Price (1-hour)",
"分时缓存 (Claude)": "Timed Cache (Claude)",
"通用缓存": "Generic Cache",
"缓存读取": "Cache Read",
"缓存创建": "Cache Creation",
"缓存创建-5分钟": "Cache Creation (5-min)",
"缓存创建-1小时": "Cache Creation (1-hour)",
"缓存读取 Token (cr)": "Cache Read Tokens (cr)",
"缓存创建 Token (cc)": "Cache Creation Tokens (cc)",
"缓存创建-5分钟 (cc5)": "Cache Creation-5min (cc5)",
"缓存创建-1小时 (cc1h)": "Cache Creation-1hour (cc1h)",
"图片输入价格": "Image Input Price", "图片输入价格": "Image Input Price",
"音频输入价格": "Audio Input Price", "音频输入价格": "Audio Input Price",
"音频输入价格:{{symbol}}{{price}} / 1M tokens": "Audio input price: {{symbol}}{{price}} / 1M tokens", "音频输入价格:{{symbol}}{{price}} / 1M tokens": "Audio input price: {{symbol}}{{price}} / 1M tokens",
...@@ -3309,6 +3323,104 @@ ...@@ -3309,6 +3323,104 @@
"输入价格:{{symbol}}{{price}} / 1M tokens": "Input Price: {{symbol}}{{price}} / 1M tokens", "输入价格:{{symbol}}{{price}} / 1M tokens": "Input Price: {{symbol}}{{price}} / 1M tokens",
"输出价格 {{symbol}}{{price}} / 1M tokens": "Output Price {{symbol}}{{price}} / 1M tokens", "输出价格 {{symbol}}{{price}} / 1M tokens": "Output Price {{symbol}}{{price}} / 1M tokens",
"输出价格:{{symbol}}{{price}} / 1M tokens": "Output Price: {{symbol}}{{price}} / 1M tokens", "输出价格:{{symbol}}{{price}} / 1M tokens": "Output Price: {{symbol}}{{price}} / 1M tokens",
"输出价格:{{symbol}}{{total}} / 1M tokens": "Output Price: {{symbol}}{{total}} / 1M tokens" "输出价格:{{symbol}}{{total}} / 1M tokens": "Output Price: {{symbol}}{{total}} / 1M tokens",
"阶梯计费": "Tiered Billing",
"输入 Tokens 阶梯": "Input Token Tiers",
"输出 Tokens 阶梯": "Output Token Tiers",
"固定阶梯": "Fixed Tier",
"累进阶梯": "Graduated Tier",
"上限": "Up To",
"单价": "Unit Cost",
"固定费": "Flat Fee",
"Expr 预览": "Expression Preview",
"Token 估算器": "Token Estimator",
"预计费用": "Estimated Cost",
"原始额度": "Raw Quota",
"添加阶梯": "Add Tier",
"无限": "Unlimited",
"输入 Token 定价": "Input Token Pricing",
"输出 Token 定价": "Output Token Pricing",
"统一定价": "Flat Rate",
"阶梯累进": "Graduated",
"根据总用量落在哪个档位,所有 Token 都按该档价格计费": "All tokens are charged at the rate of the tier your total usage falls into",
"用量分段计价,每一段各自按对应档位价格计费(类似电费阶梯)": "Usage is charged in segments — each segment at its own tier rate (like utility billing)",
"Token 用量范围": "Token Usage Range",
"所有 Token": "All Tokens",
"前 {{count}} 个": "First {{count}}",
"超过 {{count}} 个": "Over {{count}}",
"第 {{n}} 档": "Tier {{n}}",
"最高档": "Highest Tier",
"此档上限(Token 数)": "Tier Limit (Token Count)",
"每百万 Token 价格": "Price per 1M Tokens",
"进入此档额外收费": "Tier Entry Fee",
"可选,用量达到此档时加收的固定费用": "Optional fixed fee charged when usage reaches this tier",
"添加更多档位": "Add More Tiers",
"输入 Token 数": "Input Tokens",
"输出 Token 数": "Output Tokens",
"输入 Token 数量,查看按当前阶梯配置的预计费用。": "Enter token counts to see the estimated cost with the current tier configuration.",
"开发者": "Developer",
"阶梯计费详情": "Tiered Billing Details",
"预估环境": "Estimated Env",
"实际环境": "Actual Env",
"预估额度": "Estimated Quota",
"实际额度": "Actual Quota",
"跨阶梯": "Crossed Tier",
"是": "Yes",
"否": "No",
"计费明细": "Billing Breakdown",
"阶梯序号": "Tier #",
"Token 类型": "Token Type",
"阶梯内 Token 数": "Tokens in Tier",
"小计": "Subtotal",
"输入": "Input",
"输出": "Output",
"阶梯配置摘要": "Tier Config Summary",
"输入阶梯": "Input Tiers",
"档位名称": "Tier Name",
"用量范围": "Usage Range",
"输入 Token": "Input Token",
"输出 Token": "Output Token",
"阶梯判断依据": "Tier Criterion",
"根据哪个维度的 Token 数量决定落在哪一档": "Determines which tier to apply based on this dimension's token count",
"输入 Token 数 (p)": "Input Tokens (p)",
"输出 Token 数 (c)": "Output Tokens (c)",
"变量": "Variables",
"函数": "Functions",
"输入计费表达式...": "Enter billing expression...",
"表达式编辑": "Expression Editor",
"表达式错误": "Expression Error",
"命中档位": "Matched Tier",
"档": "tier(s)",
"输入 Token 数量,查看按当前配置的预计费用。": "Enter token counts to see the estimated cost.",
"输入 Token 数量,查看按当前配置的预计费用(不含分组倍率)。": "Enter token counts to see the estimated cost (before group ratio).",
"条件": "Condition",
"添加条件": "Add Condition",
"无条件(兜底档)": "No condition (fallback)",
"兜底档": "Fallback",
"预设模板": "Presets",
"每个档位可设置 0~2 个条件(对 p 和 c),最后一档为兜底档无需条件。": "Each tier can have 0-2 conditions (on p and c). The last tier is the fallback and needs no condition.",
"输出阶梯": "Output Tiers",
"阶": "tiers",
"规则版本": "Rule Version",
"时间条件": "Time condition",
"小时": "Hour",
"分钟": "Minute",
"星期": "Weekday",
"月份": "Month",
"日期": "Day",
"时区": "Timezone",
"跨夜范围": "Cross-midnight range",
"添加时间规则": "Add time rule",
"起": "From",
"止": "To",
"值": "Value",
"添加条件组": "Add condition group",
"添加条件": "Add condition",
"添加时间条件": "Add time condition",
"同时满足": "all must match",
"新年促销": "New Year promo",
"第 {{n}} 组": "Group {{n}}",
"0=周日 1=周一 2=周二 3=周三 4=周四 5=周五 6=周六": "0=Sun 1=Mon 2=Tue 3=Wed 4=Thu 5=Fri 6=Sat",
"1=一月 ... 12=十二月": "1=Jan ... 12=Dec"
} }
} }
...@@ -2858,6 +2858,20 @@ ...@@ -2858,6 +2858,20 @@
"补全价格": "补全价格", "补全价格": "补全价格",
"缓存读取价格": "缓存读取价格", "缓存读取价格": "缓存读取价格",
"缓存创建价格": "缓存创建价格", "缓存创建价格": "缓存创建价格",
"缓存创建价格-5分钟": "缓存创建价格-5分钟",
"缓存创建价格-1小时": "缓存创建价格-1小时",
"缓存创建价格(5分钟)": "缓存创建价格(5分钟)",
"缓存创建价格(1小时)": "缓存创建价格(1小时)",
"分时缓存 (Claude)": "分时缓存 (Claude)",
"通用缓存": "通用缓存",
"缓存读取": "缓存读取",
"缓存创建": "缓存创建",
"缓存创建-5分钟": "缓存创建-5分钟",
"缓存创建-1小时": "缓存创建-1小时",
"缓存读取 Token (cr)": "缓存读取 Token (cr)",
"缓存创建 Token (cc)": "缓存创建 Token (cc)",
"缓存创建-5分钟 (cc5)": "缓存创建-5分钟 (cc5)",
"缓存创建-1小时 (cc1h)": "缓存创建-1小时 (cc1h)",
"图片输入价格": "图片输入价格", "图片输入价格": "图片输入价格",
"音频输入价格": "音频输入价格", "音频输入价格": "音频输入价格",
"音频补全价格": "音频补全价格", "音频补全价格": "音频补全价格",
...@@ -2938,6 +2952,102 @@ ...@@ -2938,6 +2952,102 @@
"输入价格:{{symbol}}{{price}} / 1M tokens": "输入价格:{{symbol}}{{price}} / 1M tokens", "输入价格:{{symbol}}{{price}} / 1M tokens": "输入价格:{{symbol}}{{price}} / 1M tokens",
"输出价格 {{symbol}}{{price}} / 1M tokens": "输出价格 {{symbol}}{{price}} / 1M tokens", "输出价格 {{symbol}}{{price}} / 1M tokens": "输出价格 {{symbol}}{{price}} / 1M tokens",
"输出价格:{{symbol}}{{price}} / 1M tokens": "输出价格:{{symbol}}{{price}} / 1M tokens", "输出价格:{{symbol}}{{price}} / 1M tokens": "输出价格:{{symbol}}{{price}} / 1M tokens",
"输出价格:{{symbol}}{{total}} / 1M tokens": "输出价格:{{symbol}}{{total}} / 1M tokens" "输出价格:{{symbol}}{{total}} / 1M tokens": "输出价格:{{symbol}}{{total}} / 1M tokens",
"阶梯计费": "阶梯计费",
"输入 Tokens 阶梯": "输入 Tokens 阶梯",
"输出 Tokens 阶梯": "输出 Tokens 阶梯",
"固定阶梯": "固定阶梯",
"累进阶梯": "累进阶梯",
"上限": "上限",
"单价": "单价",
"固定费": "固定费",
"Expr 预览": "Expr 预览",
"Token 估算器": "Token 估算器",
"预计费用": "预计费用",
"添加阶梯": "添加阶梯",
"无限": "无限",
"输入 Token 定价": "输入 Token 定价",
"输出 Token 定价": "输出 Token 定价",
"统一定价": "统一定价",
"阶梯累进": "阶梯累进",
"根据总用量落在哪个档位,所有 Token 都按该档价格计费": "根据总用量落在哪个档位,所有 Token 都按该档价格计费",
"用量分段计价,每一段各自按对应档位价格计费(类似电费阶梯)": "用量分段计价,每一段各自按对应档位价格计费(类似电费阶梯)",
"Token 用量范围": "Token 用量范围",
"所有 Token": "所有 Token",
"前 {{count}} 个": "前 {{count}} 个",
"超过 {{count}} 个": "超过 {{count}} 个",
"第 {{n}} 档": "第 {{n}} 档",
"最高档": "最高档",
"此档上限(Token 数)": "此档上限(Token 数)",
"每百万 Token 价格": "每百万 Token 价格",
"进入此档额外收费": "进入此档额外收费",
"可选,用量达到此档时加收的固定费用": "可选,用量达到此档时加收的固定费用",
"添加更多档位": "添加更多档位",
"输入 Token 数": "输入 Token 数",
"输出 Token 数": "输出 Token 数",
"输入 Token 数量,查看按当前阶梯配置的预计费用。": "输入 Token 数量,查看按当前阶梯配置的预计费用。",
"开发者": "开发者",
"阶梯计费详情": "阶梯计费详情",
"预估环境": "预估环境",
"实际环境": "实际环境",
"预估额度": "预估额度",
"实际额度": "实际额度",
"跨阶梯": "跨阶梯",
"是": "是",
"否": "否",
"计费明细": "计费明细",
"阶梯序号": "阶梯序号",
"Token 类型": "Token 类型",
"阶梯内 Token 数": "阶梯内 Token 数",
"小计": "小计",
"输入": "输入",
"档位标签": "档位标签",
"用量范围": "用量范围",
"输入 Token": "输入 Token",
"输出 Token": "输出 Token",
"阶梯判断依据": "阶梯判断依据",
"根据哪个维度的 Token 数量决定落在哪一档": "根据哪个维度的 Token 数量决定落在哪一档",
"输入 Token 数 (p)": "输入 Token 数 (p)",
"输出 Token 数 (c)": "输出 Token 数 (c)",
"变量": "变量",
"函数": "函数",
"输入计费表达式...": "输入计费表达式...",
"表达式编辑": "表达式编辑",
"表达式错误": "表达式错误",
"命中档位": "命中档位",
"档": "档",
"输入 Token 数量,查看按当前配置的预计费用。": "输入 Token 数量,查看按当前配置的预计费用。",
"条件": "条件",
"添加条件": "添加条件",
"无条件(兜底档)": "无条件(兜底档)",
"兜底档": "兜底档",
"预设模板": "预设模板",
"每个档位可设置 0~2 个条件(对 p 和 c),最后一档为兜底档无需条件。": "每个档位可设置 0~2 个条件(对 p 和 c),最后一档为兜底档无需条件。",
"输出": "输出",
"阶梯配置摘要": "阶梯配置摘要",
"输入阶梯": "输入阶梯",
"输出阶梯": "输出阶梯",
"阶": "阶",
"规则版本": "规则版本",
"时间条件": "时间条件",
"小时": "小时",
"分钟": "分钟",
"星期": "星期",
"月份": "月份",
"日期": "日期",
"时区": "时区",
"跨夜范围": "跨夜范围",
"添加时间规则": "添加时间规则",
"起": "起",
"止": "止",
"值": "值",
"添加条件组": "添加条件组",
"添加条件": "添加条件",
"添加时间条件": "添加时间条件",
"同时满足": "同时满足",
"新年促销": "新年促销",
"第 {{n}} 组": "第 {{n}} 组",
"0=周日 1=周一 2=周二 3=周三 4=周四 5=周五 6=周六": "0=周日 1=周一 2=周二 3=周三 4=周四 5=周五 6=周六",
"1=一月 ... 12=十二月": "1=一月 ... 12=十二月"
} }
} }
...@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import React, { useMemo, useState } from 'react'; import React, { useCallback, useMemo, useState } from 'react';
import { import {
Banner, Banner,
Button, Button,
...@@ -49,6 +49,7 @@ import { ...@@ -49,6 +49,7 @@ import {
useModelPricingEditorState, useModelPricingEditorState,
} from '../hooks/useModelPricingEditorState'; } from '../hooks/useModelPricingEditorState';
import { useIsMobile } from '../../../../hooks/common/useIsMobile'; import { useIsMobile } from '../../../../hooks/common/useIsMobile';
import TieredPricingEditor from './TieredPricingEditor';
const { Text } = Typography; const { Text } = Typography;
const EMPTY_CANDIDATE_MODEL_NAMES = []; const EMPTY_CANDIDATE_MODEL_NAMES = [];
...@@ -123,6 +124,8 @@ export default function ModelPricingEditor({ ...@@ -123,6 +124,8 @@ export default function ModelPricingEditor({
handleOptionalFieldToggle, handleOptionalFieldToggle,
handleNumericFieldChange, handleNumericFieldChange,
handleBillingModeChange, handleBillingModeChange,
handleBillingExprChange,
handleRequestRuleExprChange,
handleSubmit, handleSubmit,
addModel, addModel,
deleteModel, deleteModel,
...@@ -135,6 +138,15 @@ export default function ModelPricingEditor({ ...@@ -135,6 +138,15 @@ export default function ModelPricingEditor({
filterMode, filterMode,
}); });
const getExprModeLabel = useCallback((model) => {
if (model?.billingMode !== 'tiered_expr') {
return '';
}
return (model.billingExpr || '').includes('tier(')
? t('阶梯计费')
: t('表达式计费');
}, [t]);
const columns = useMemo( const columns = useMemo(
() => [ () => [
{ {
...@@ -175,10 +187,20 @@ export default function ModelPricingEditor({ ...@@ -175,10 +187,20 @@ export default function ModelPricingEditor({
dataIndex: 'billingMode', dataIndex: 'billingMode',
key: 'billingMode', key: 'billingMode',
render: (_, record) => ( render: (_, record) => (
<Tag color={record.billingMode === 'per-request' ? 'teal' : 'violet'}> <Tag
color={
record.billingMode === 'per-request'
? 'teal'
: record.billingMode === 'tiered_expr'
? 'amber'
: 'violet'
}
>
{record.billingMode === 'per-request' {record.billingMode === 'per-request'
? t('按次计费') ? t('按次计费')
: t('按量计费')} : record.billingMode === 'tiered_expr'
? getExprModeLabel(record)
: t('按量计费')}
</Tag> </Tag>
), ),
}, },
...@@ -208,6 +230,7 @@ export default function ModelPricingEditor({ ...@@ -208,6 +230,7 @@ export default function ModelPricingEditor({
[ [
allowDeleteModel, allowDeleteModel,
deleteModel, deleteModel,
getExprModeLabel,
selectedModelName, selectedModelName,
selectedModelNames, selectedModelNames,
setSelectedModelName, setSelectedModelName,
...@@ -353,10 +376,20 @@ export default function ModelPricingEditor({ ...@@ -353,10 +376,20 @@ export default function ModelPricingEditor({
title={selectedModel ? selectedModel.name : t('模型计费编辑器')} title={selectedModel ? selectedModel.name : t('模型计费编辑器')}
headerExtraContent={ headerExtraContent={
selectedModel ? ( selectedModel ? (
<Tag color='blue'> <Tag
color={
selectedModel.billingMode === 'per-request'
? 'teal'
: selectedModel.billingMode === 'tiered_expr'
? 'amber'
: 'blue'
}
>
{selectedModel.billingMode === 'per-request' {selectedModel.billingMode === 'per-request'
? t('按次计费') ? t('按次计费')
: t('按量计费')} : selectedModel.billingMode === 'tiered_expr'
? getExprModeLabel(selectedModel)
: t('按量计费')}
</Tag> </Tag>
) : null ) : null
} }
...@@ -381,10 +414,11 @@ export default function ModelPricingEditor({ ...@@ -381,10 +414,11 @@ export default function ModelPricingEditor({
> >
<Radio value='per-token'>{t('按量计费')}</Radio> <Radio value='per-token'>{t('按量计费')}</Radio>
<Radio value='per-request'>{t('按次计费')}</Radio> <Radio value='per-request'>{t('按次计费')}</Radio>
<Radio value='tiered_expr'>{t('表达式/阶梯计费')}</Radio>
</RadioGroup> </RadioGroup>
<div className='mt-2 text-xs text-gray-500'> <div className='mt-2 text-xs text-gray-500'>
{t( {t(
'这个界面默认按价格填写,保存时会自动换算回后端需要的倍率 JSON。', '普通按量/按次直接填价格就行;如果价格要跟请求参数或请求头联动,请切到表达式/阶梯计费。',
)} )}
</div> </div>
</div> </div>
...@@ -415,6 +449,14 @@ export default function ModelPricingEditor({ ...@@ -415,6 +449,14 @@ export default function ModelPricingEditor({
onChange={(value) => handleNumericFieldChange('fixedPrice', value)} onChange={(value) => handleNumericFieldChange('fixedPrice', value)}
extraText={t('适合 MJ / 任务类等按次收费模型。')} extraText={t('适合 MJ / 任务类等按次收费模型。')}
/> />
) : selectedModel.billingMode === 'tiered_expr' ? (
<TieredPricingEditor
model={selectedModel}
onExprChange={handleBillingExprChange}
requestRuleExpr={selectedModel.requestRuleExpr}
onRequestRuleExprChange={handleRequestRuleExprChange}
t={t}
/>
) : ( ) : (
<> <>
<Card <Card
......
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { API, showError, showSuccess } from '../../../../helpers'; import { API, showError, showSuccess } from '../../../../helpers';
import {
combineBillingExpr,
splitBillingExprAndRequestRules,
} from '../components/requestRuleExpr';
export const PAGE_SIZE = 10; export const PAGE_SIZE = 10;
export const PRICE_SUFFIX = '$/1M tokens'; export const PRICE_SUFFIX = '$/1M tokens';
...@@ -18,6 +22,8 @@ const EMPTY_MODEL = { ...@@ -18,6 +22,8 @@ const EMPTY_MODEL = {
imagePrice: '', imagePrice: '',
audioInputPrice: '', audioInputPrice: '',
audioOutputPrice: '', audioOutputPrice: '',
billingExpr: '',
requestRuleExpr: '',
rawRatios: { rawRatios: {
modelRatio: '', modelRatio: '',
completionRatio: '', completionRatio: '',
...@@ -98,6 +104,22 @@ const normalizeCompletionRatioMeta = (rawMeta) => { ...@@ -98,6 +104,22 @@ const normalizeCompletionRatioMeta = (rawMeta) => {
}; };
const buildModelState = (name, sourceMaps) => { const buildModelState = (name, sourceMaps) => {
const billingMode = sourceMaps.ModelBillingMode?.[name];
if (billingMode === 'tiered_expr') {
const fullBillingExpr = sourceMaps.ModelBillingExpr?.[name] || '';
const { billingExpr, requestRuleExpr } =
splitBillingExprAndRequestRules(fullBillingExpr);
return {
...EMPTY_MODEL,
name,
billingMode: 'tiered_expr',
billingExpr,
requestRuleExpr,
rawRatios: { ...EMPTY_MODEL.rawRatios },
hasConflict: false,
};
}
const modelRatio = toNumericString(sourceMaps.ModelRatio[name]); const modelRatio = toNumericString(sourceMaps.ModelRatio[name]);
const completionRatio = toNumericString(sourceMaps.CompletionRatio[name]); const completionRatio = toNumericString(sourceMaps.CompletionRatio[name]);
const completionRatioMeta = normalizeCompletionRatioMeta( const completionRatioMeta = normalizeCompletionRatioMeta(
...@@ -159,6 +181,7 @@ const buildModelState = (name, sourceMaps) => { ...@@ -159,6 +181,7 @@ const buildModelState = (name, sourceMaps) => {
toNumberOrNull(audioInputPrice) !== null && hasValue(audioCompletionRatio) toNumberOrNull(audioInputPrice) !== null && hasValue(audioCompletionRatio)
? formatNumber(Number(audioInputPrice) * Number(audioCompletionRatio)) ? formatNumber(Number(audioInputPrice) * Number(audioCompletionRatio))
: '', : '',
requestRuleExpr: '',
rawRatios: { rawRatios: {
modelRatio, modelRatio,
completionRatio, completionRatio,
...@@ -183,12 +206,16 @@ const buildModelState = (name, sourceMaps) => { ...@@ -183,12 +206,16 @@ const buildModelState = (name, sourceMaps) => {
}; };
export const isBasePricingUnset = (model) => export const isBasePricingUnset = (model) =>
model.billingMode !== 'tiered_expr' &&
!hasValue(model.fixedPrice) && !hasValue(model.inputPrice); !hasValue(model.fixedPrice) && !hasValue(model.inputPrice);
export const getModelWarnings = (model, t) => { export const getModelWarnings = (model, t) => {
if (!model) { if (!model) {
return []; return [];
} }
if (model.billingMode === 'tiered_expr') {
return [];
}
const warnings = []; const warnings = [];
const hasDerivedPricing = [ const hasDerivedPricing = [
model.inputPrice, model.inputPrice,
...@@ -244,8 +271,22 @@ export const getModelWarnings = (model, t) => { ...@@ -244,8 +271,22 @@ export const getModelWarnings = (model, t) => {
}; };
export const buildSummaryText = (model, t) => { export const buildSummaryText = (model, t) => {
const requestRuleSuffix =
model.billingMode === 'tiered_expr' && model.requestRuleExpr
? `,${t('请求规则')}`
: '';
if (model.billingMode === 'tiered_expr') {
const expr = model.billingExpr;
if (!expr) return `${t('表达式计费')}${requestRuleSuffix}`;
const tierCount = (expr.match(/tier\(/g) || []).length;
if (tierCount === 0) {
return `${t('表达式计费')}${requestRuleSuffix}`;
}
return `${t('阶梯计费')} (${tierCount} ${t('档')})${requestRuleSuffix}`;
}
if (model.billingMode === 'per-request' && hasValue(model.fixedPrice)) { if (model.billingMode === 'per-request' && hasValue(model.fixedPrice)) {
return `${t('按次')} $${model.fixedPrice} / ${t('次')}`; return `${t('按次')} $${model.fixedPrice} / ${t('次')}${requestRuleSuffix}`;
} }
if (hasValue(model.inputPrice)) { if (hasValue(model.inputPrice)) {
...@@ -259,10 +300,10 @@ export const buildSummaryText = (model, t) => { ...@@ -259,10 +300,10 @@ export const buildSummaryText = (model, t) => {
].filter(hasValue).length; ].filter(hasValue).length;
const extraLabel = const extraLabel =
extraCount > 0 ? `,${t('额外价格项')} ${extraCount}` : ''; extraCount > 0 ? `,${t('额外价格项')} ${extraCount}` : '';
return `${t('输入')} $${model.inputPrice}${extraLabel}`; return `${t('输入')} $${model.inputPrice}${extraLabel}${requestRuleSuffix}`;
} }
return t('未设置价格'); return `${t('未设置价格')}${requestRuleSuffix}`;
}; };
export const buildOptionalFieldToggles = (model) => ({ export const buildOptionalFieldToggles = (model) => ({
...@@ -395,20 +436,53 @@ const serializeModel = (model, t) => { ...@@ -395,20 +436,53 @@ const serializeModel = (model, t) => {
export const buildPreviewRows = (model, t) => { export const buildPreviewRows = (model, t) => {
if (!model) return []; if (!model) return [];
const finalBillingExpr = combineBillingExpr(
model.billingExpr,
model.requestRuleExpr,
);
if (model.billingMode === 'tiered_expr') {
const rows = [
{
key: 'BillingMode',
label: 'ModelBillingMode',
value: 'tiered_expr',
},
];
if (finalBillingExpr) {
const tierCount = (model.billingExpr.match(/tier\(/g) || []).length;
rows.push({
key: 'BillingExpr',
label: 'ModelBillingExpr',
value:
tierCount > 0
? `${tierCount} ${t('档')}${
finalBillingExpr.length > 60
? finalBillingExpr.slice(0, 60) + '...'
: finalBillingExpr
}`
: finalBillingExpr.length > 60
? finalBillingExpr.slice(0, 60) + '...'
: finalBillingExpr,
});
}
return rows;
}
if (model.billingMode === 'per-request') { if (model.billingMode === 'per-request') {
return [ const rows = [
{ {
key: 'ModelPrice', key: 'ModelPrice',
label: 'ModelPrice', label: 'ModelPrice',
value: hasValue(model.fixedPrice) ? model.fixedPrice : t('空'), value: hasValue(model.fixedPrice) ? model.fixedPrice : t('空'),
}, },
]; ];
return rows;
} }
const inputPrice = toNumberOrNull(model.inputPrice); const inputPrice = toNumberOrNull(model.inputPrice);
if (inputPrice === null) { if (inputPrice === null) {
return [ const rows = [
{ {
key: 'ModelRatio', key: 'ModelRatio',
label: 'ModelRatio', label: 'ModelRatio',
...@@ -459,6 +533,7 @@ export const buildPreviewRows = (model, t) => { ...@@ -459,6 +533,7 @@ export const buildPreviewRows = (model, t) => {
: t('空'), : t('空'),
}, },
]; ];
return rows;
} }
const completionPrice = toNumberOrNull(model.completionPrice); const completionPrice = toNumberOrNull(model.completionPrice);
...@@ -468,7 +543,7 @@ export const buildPreviewRows = (model, t) => { ...@@ -468,7 +543,7 @@ export const buildPreviewRows = (model, t) => {
const audioInputPrice = toNumberOrNull(model.audioInputPrice); const audioInputPrice = toNumberOrNull(model.audioInputPrice);
const audioOutputPrice = toNumberOrNull(model.audioOutputPrice); const audioOutputPrice = toNumberOrNull(model.audioOutputPrice);
return [ const rows = [
{ {
key: 'ModelRatio', key: 'ModelRatio',
label: 'ModelRatio', label: 'ModelRatio',
...@@ -522,6 +597,7 @@ export const buildPreviewRows = (model, t) => { ...@@ -522,6 +597,7 @@ export const buildPreviewRows = (model, t) => {
: t('空'), : t('空'),
}, },
]; ];
return rows;
}; };
export function useModelPricingEditorState({ export function useModelPricingEditorState({
...@@ -552,6 +628,8 @@ export function useModelPricingEditorState({ ...@@ -552,6 +628,8 @@ export function useModelPricingEditorState({
ImageRatio: parseOptionJSON(options.ImageRatio), ImageRatio: parseOptionJSON(options.ImageRatio),
AudioRatio: parseOptionJSON(options.AudioRatio), AudioRatio: parseOptionJSON(options.AudioRatio),
AudioCompletionRatio: parseOptionJSON(options.AudioCompletionRatio), AudioCompletionRatio: parseOptionJSON(options.AudioCompletionRatio),
ModelBillingMode: parseOptionJSON(options.ModelBillingMode),
ModelBillingExpr: parseOptionJSON(options.ModelBillingExpr),
}; };
const names = new Set([ const names = new Set([
...@@ -565,6 +643,8 @@ export function useModelPricingEditorState({ ...@@ -565,6 +643,8 @@ export function useModelPricingEditorState({
...Object.keys(sourceMaps.ImageRatio), ...Object.keys(sourceMaps.ImageRatio),
...Object.keys(sourceMaps.AudioRatio), ...Object.keys(sourceMaps.AudioRatio),
...Object.keys(sourceMaps.AudioCompletionRatio), ...Object.keys(sourceMaps.AudioCompletionRatio),
...Object.keys(sourceMaps.ModelBillingMode),
...Object.keys(sourceMaps.ModelBillingExpr),
]); ]);
const nextModels = Array.from(names) const nextModels = Array.from(names)
...@@ -776,9 +856,28 @@ export function useModelPricingEditorState({ ...@@ -776,9 +856,28 @@ export function useModelPricingEditorState({
const handleBillingModeChange = (value) => { const handleBillingModeChange = (value) => {
if (!selectedModel) return; if (!selectedModel) return;
upsertModel(selectedModel.name, (model) => {
const next = { ...model, billingMode: value };
if (value === 'tiered_expr' && !model.billingExpr) {
next.billingExpr = 'tier("default", p * 0 + c * 0)';
}
return next;
});
};
const handleBillingExprChange = (newExpr) => {
if (!selectedModel) return;
upsertModel(selectedModel.name, (model) => ({ upsertModel(selectedModel.name, (model) => ({
...model, ...model,
billingMode: value, billingExpr: newExpr,
}));
};
const handleRequestRuleExprChange = (newExpr) => {
if (!selectedModel) return;
upsertModel(selectedModel.name, (model) => ({
...model,
requestRuleExpr: newExpr,
})); }));
}; };
...@@ -854,6 +953,8 @@ export function useModelPricingEditorState({ ...@@ -854,6 +953,8 @@ export function useModelPricingEditorState({
imagePrice: selectedModel.imagePrice, imagePrice: selectedModel.imagePrice,
audioInputPrice: selectedModel.audioInputPrice, audioInputPrice: selectedModel.audioInputPrice,
audioOutputPrice: selectedModel.audioOutputPrice, audioOutputPrice: selectedModel.audioOutputPrice,
billingExpr: selectedModel.billingExpr || '',
requestRuleExpr: selectedModel.requestRuleExpr || '',
}; };
if ( if (
...@@ -915,7 +1016,26 @@ export function useModelPricingEditorState({ ...@@ -915,7 +1016,26 @@ export function useModelPricingEditorState({
AudioCompletionRatio: {}, AudioCompletionRatio: {},
}; };
const tieredOutput = {
ModelBillingMode: {},
ModelBillingExpr: {},
};
for (const model of models) { for (const model of models) {
if (model.billingMode === 'tiered_expr') {
tieredOutput.ModelBillingMode[model.name] = 'tiered_expr';
const finalBillingExpr = combineBillingExpr(
model.billingExpr,
model.requestRuleExpr,
);
if (finalBillingExpr) {
tieredOutput.ModelBillingExpr[model.name] = finalBillingExpr;
}
}
if (model.billingMode === 'tiered_expr') {
continue;
}
const serialized = serializeModel(model, t); const serialized = serializeModel(model, t);
Object.entries(serialized).forEach(([key, value]) => { Object.entries(serialized).forEach(([key, value]) => {
if (value !== null) { if (value !== null) {
...@@ -924,12 +1044,20 @@ export function useModelPricingEditorState({ ...@@ -924,12 +1044,20 @@ export function useModelPricingEditorState({
}); });
} }
const requestQueue = Object.entries(output).map(([key, value]) => const requestQueue = [
API.put('/api/option/', { ...Object.entries(output).map(([key, value]) =>
key, API.put('/api/option/', {
value: JSON.stringify(value, null, 2), key,
}), value: JSON.stringify(value, null, 2),
); }),
),
...Object.entries(tieredOutput).map(([key, value]) =>
API.put('/api/option/', {
key,
value: JSON.stringify(value, null, 2),
}),
),
];
const results = await Promise.all(requestQueue); const results = await Promise.all(requestQueue);
for (const res of results) { for (const res of results) {
...@@ -970,6 +1098,8 @@ export function useModelPricingEditorState({ ...@@ -970,6 +1098,8 @@ export function useModelPricingEditorState({
handleOptionalFieldToggle, handleOptionalFieldToggle,
handleNumericFieldChange, handleNumericFieldChange,
handleBillingModeChange, handleBillingModeChange,
handleBillingExprChange,
handleRequestRuleExprChange,
handleSubmit, handleSubmit,
addModel, addModel,
deleteModel, deleteModel,
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment