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.
This commit is contained in:
@@ -27,6 +27,8 @@ type BillingSession struct {
|
||||
funding FundingSource
|
||||
preConsumedQuota int // 实际预扣额度(信任用户可能为 0)
|
||||
tokenConsumed int // 令牌额度实际扣减量
|
||||
extraReserved int // 发送前补充预扣的额度(订阅退款时需要单独回滚)
|
||||
trusted bool // 是否命中信任额度旁路
|
||||
fundingSettled bool // funding.Settle 已成功,资金来源已提交
|
||||
settled bool // Settle 全部完成(资金 + 令牌)
|
||||
refunded bool // Refund 已调用
|
||||
@@ -97,6 +99,8 @@ func (s *BillingSession) Refund(c *gin.Context) {
|
||||
tokenKey := s.relayInfo.TokenKey
|
||||
isPlayground := s.relayInfo.IsPlayground
|
||||
tokenConsumed := s.tokenConsumed
|
||||
extraReserved := s.extraReserved
|
||||
subscriptionId := s.relayInfo.SubscriptionId
|
||||
funding := s.funding
|
||||
|
||||
gopool.Go(func() {
|
||||
@@ -104,6 +108,11 @@ func (s *BillingSession) Refund(c *gin.Context) {
|
||||
if err := funding.Refund(); err != nil {
|
||||
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) 退还令牌额度
|
||||
if tokenConsumed > 0 && !isPlayground {
|
||||
if err := model.IncreaseTokenQuota(tokenId, tokenKey, tokenConsumed); err != nil {
|
||||
@@ -140,6 +149,34 @@ func (s *BillingSession) GetPreConsumedQuota() int {
|
||||
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 — 统一预扣费入口(含信任额度旁路)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -151,6 +188,7 @@ func (s *BillingSession) preConsume(c *gin.Context, quota int) *types.NewAPIErro
|
||||
|
||||
// ---- 信任额度旁路 ----
|
||||
if s.shouldTrust(c) {
|
||||
s.trusted = true
|
||||
effectiveQuota = 0
|
||||
logger.LogInfo(c, fmt.Sprintf("用户 %d 额度充足, 信任且不需要预扣费 (funding=%s)", s.relayInfo.UserId, s.funding.Source()))
|
||||
} else if effectiveQuota > 0 {
|
||||
@@ -191,6 +229,55 @@ func (s *BillingSession) preConsume(c *gin.Context, quota int) *types.NewAPIErro
|
||||
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 统一信任额度检查,适用于钱包和订阅。
|
||||
func (s *BillingSession) shouldTrust(c *gin.Context) bool {
|
||||
// 异步任务(ForcePreConsume=true)必须预扣全额,不允许信任旁路
|
||||
@@ -235,10 +322,10 @@ func (s *BillingSession) syncRelayInfo() {
|
||||
|
||||
if sub, ok := s.funding.(*SubscriptionFunding); ok {
|
||||
info.SubscriptionId = sub.subscriptionId
|
||||
info.SubscriptionPreConsumed = sub.preConsumed
|
||||
info.SubscriptionPreConsumed = sub.preConsumed + int64(s.extraReserved)
|
||||
info.SubscriptionPostDelta = 0
|
||||
info.SubscriptionAmountTotal = sub.AmountTotal
|
||||
info.SubscriptionAmountUsedAfterPreConsume = sub.AmountUsedAfter
|
||||
info.SubscriptionAmountUsedAfterPreConsume = sub.AmountUsedAfter + int64(s.extraReserved)
|
||||
info.SubscriptionPlanId = sub.PlanId
|
||||
info.SubscriptionPlanTitle = sub.PlanTitle
|
||||
} else {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/pkg/billingexpr"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
|
||||
@@ -214,3 +215,42 @@ func GenerateMjOtherInfo(relayInfo *relaycommon.RelayInfo, priceData types.Price
|
||||
appendRequestPath(nil, relayInfo, 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 (
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/pkg/billingexpr"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||
"github.com/QuantumNous/new-api/setting/system_setting"
|
||||
@@ -157,6 +158,15 @@ func PreWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usag
|
||||
func PostWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, modelName 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()
|
||||
textInputTokens := usage.InputTokenDetails.TextTokens
|
||||
textOutTokens := usage.OutputTokenDetails.TextTokens
|
||||
@@ -240,6 +250,18 @@ func PostClaudeConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
|
||||
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()
|
||||
promptTokens := usage.PromptTokens
|
||||
completionTokens := usage.CompletionTokens
|
||||
@@ -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) {
|
||||
|
||||
// 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()
|
||||
textInputTokens := usage.PromptTokensDetails.TextTokens
|
||||
textOutTokens := usage.CompletionTokenDetails.TextTokens
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/pkg/billingexpr"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
)
|
||||
|
||||
// Claude Sonnet-style tiered expression: standard vs long-context
|
||||
const sonnetTieredExpr = `p <= 200000 ? tier("standard", p * 1.5 + c * 7.5) : tier("long_context", p * 3 + c * 11.25)`
|
||||
|
||||
// Simple flat expression
|
||||
const flatExpr = `tier("default", p * 2 + c * 10)`
|
||||
|
||||
// Expression with cache tokens
|
||||
const cacheExpr = `tier("default", p * 2 + c * 10 + cr * 0.2 + cc * 2.5 + cc1h * 4)`
|
||||
|
||||
// Expression with request probes
|
||||
const probeExpr = `param("service_tier") == "fast" ? tier("fast", p * 4 + c * 20) : tier("normal", p * 2 + c * 10)`
|
||||
|
||||
func makeSnapshot(expr string, groupRatio float64, estPrompt, estCompletion int) *billingexpr.BillingSnapshot {
|
||||
return &billingexpr.BillingSnapshot{
|
||||
BillingMode: "tiered_expr",
|
||||
ExprString: expr,
|
||||
ExprHash: billingexpr.ExprHashString(expr),
|
||||
GroupRatio: groupRatio,
|
||||
EstimatedPromptTokens: estPrompt,
|
||||
EstimatedCompletionTokens: estCompletion,
|
||||
}
|
||||
}
|
||||
|
||||
func makeRelayInfo(expr string, groupRatio float64, estPrompt, estCompletion int) *relaycommon.RelayInfo {
|
||||
snap := makeSnapshot(expr, groupRatio, estPrompt, estCompletion)
|
||||
cost, trace, _ := billingexpr.RunExpr(expr, billingexpr.TokenParams{P: float64(estPrompt), C: float64(estCompletion)})
|
||||
snap.EstimatedQuotaBeforeGroup = cost
|
||||
snap.EstimatedQuotaAfterGroup = billingexpr.QuotaRound(cost * groupRatio)
|
||||
snap.EstimatedTier = trace.MatchedTier
|
||||
return &relaycommon.RelayInfo{
|
||||
TieredBillingSnapshot: snap,
|
||||
FinalPreConsumedQuota: snap.EstimatedQuotaAfterGroup,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Existing tests (preserved)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestTryTieredSettleUsesFrozenRequestInput(t *testing.T) {
|
||||
exprStr := `param("service_tier") == "fast" ? tier("fast", p * 2) : tier("normal", p)`
|
||||
relayInfo := &relaycommon.RelayInfo{
|
||||
TieredBillingSnapshot: &billingexpr.BillingSnapshot{
|
||||
BillingMode: "tiered_expr",
|
||||
ExprString: exprStr,
|
||||
ExprHash: billingexpr.ExprHashString(exprStr),
|
||||
GroupRatio: 1.0,
|
||||
EstimatedPromptTokens: 100,
|
||||
EstimatedCompletionTokens: 0,
|
||||
EstimatedQuotaAfterGroup: 100,
|
||||
},
|
||||
BillingRequestInput: &billingexpr.RequestInput{
|
||||
Body: []byte(`{"service_tier":"fast"}`),
|
||||
},
|
||||
}
|
||||
|
||||
ok, quota, result := TryTieredSettle(relayInfo, billingexpr.TokenParams{P: 100})
|
||||
if !ok {
|
||||
t.Fatal("expected tiered settle to apply")
|
||||
}
|
||||
if quota != 200 {
|
||||
t.Fatalf("quota = %d, want 200", quota)
|
||||
}
|
||||
if result == nil || result.MatchedTier != "fast" {
|
||||
t.Fatalf("matched tier = %v, want fast", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTryTieredSettleFallsBackToFrozenPreConsumeOnExprError(t *testing.T) {
|
||||
relayInfo := &relaycommon.RelayInfo{
|
||||
FinalPreConsumedQuota: 321,
|
||||
TieredBillingSnapshot: &billingexpr.BillingSnapshot{
|
||||
BillingMode: "tiered_expr",
|
||||
ExprString: `invalid +-+ expr`,
|
||||
ExprHash: billingexpr.ExprHashString(`invalid +-+ expr`),
|
||||
GroupRatio: 1.0,
|
||||
EstimatedQuotaAfterGroup: 123,
|
||||
},
|
||||
}
|
||||
|
||||
ok, quota, result := TryTieredSettle(relayInfo, billingexpr.TokenParams{P: 100})
|
||||
if !ok {
|
||||
t.Fatal("expected tiered settle to apply")
|
||||
}
|
||||
if quota != 321 {
|
||||
t.Fatalf("quota = %d, want 321", quota)
|
||||
}
|
||||
if result != nil {
|
||||
t.Fatalf("result = %#v, want nil", result)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pre-consume vs Post-consume consistency
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestTryTieredSettle_PreConsumeMatchesPostConsume(t *testing.T) {
|
||||
info := makeRelayInfo(flatExpr, 1.0, 1000, 500)
|
||||
params := billingexpr.TokenParams{P: 1000, C: 500}
|
||||
|
||||
ok, quota, _ := TryTieredSettle(info, params)
|
||||
if !ok {
|
||||
t.Fatal("expected tiered settle")
|
||||
}
|
||||
// p*2 + c*10 = 2000 + 5000 = 7000
|
||||
if quota != 7000 {
|
||||
t.Fatalf("quota = %d, want 7000", quota)
|
||||
}
|
||||
if quota != info.FinalPreConsumedQuota {
|
||||
t.Fatalf("pre-consume %d != post-consume %d", info.FinalPreConsumedQuota, quota)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTryTieredSettle_PostConsumeOverPreConsume(t *testing.T) {
|
||||
info := makeRelayInfo(flatExpr, 1.0, 1000, 500)
|
||||
preConsumed := info.FinalPreConsumedQuota // 7000
|
||||
|
||||
// Actual usage is higher than estimated
|
||||
params := billingexpr.TokenParams{P: 2000, C: 1000}
|
||||
ok, quota, _ := TryTieredSettle(info, params)
|
||||
if !ok {
|
||||
t.Fatal("expected tiered settle")
|
||||
}
|
||||
// p*2 + c*10 = 4000 + 10000 = 14000
|
||||
if quota != 14000 {
|
||||
t.Fatalf("quota = %d, want 14000", quota)
|
||||
}
|
||||
if quota <= preConsumed {
|
||||
t.Fatalf("expected supplement: actual %d should > pre-consumed %d", quota, preConsumed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTryTieredSettle_PostConsumeUnderPreConsume(t *testing.T) {
|
||||
info := makeRelayInfo(flatExpr, 1.0, 1000, 500)
|
||||
preConsumed := info.FinalPreConsumedQuota // 7000
|
||||
|
||||
// Actual usage is lower than estimated
|
||||
params := billingexpr.TokenParams{P: 100, C: 50}
|
||||
ok, quota, _ := TryTieredSettle(info, params)
|
||||
if !ok {
|
||||
t.Fatal("expected tiered settle")
|
||||
}
|
||||
// p*2 + c*10 = 200 + 500 = 700
|
||||
if quota != 700 {
|
||||
t.Fatalf("quota = %d, want 700", quota)
|
||||
}
|
||||
if quota >= preConsumed {
|
||||
t.Fatalf("expected refund: actual %d should < pre-consumed %d", quota, preConsumed)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tiered boundary conditions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestTryTieredSettle_ExactBoundary(t *testing.T) {
|
||||
info := makeRelayInfo(sonnetTieredExpr, 1.0, 200000, 1000)
|
||||
|
||||
// p == 200000 => standard tier (p <= 200000)
|
||||
ok, quota, result := TryTieredSettle(info, billingexpr.TokenParams{P: 200000, C: 1000})
|
||||
if !ok {
|
||||
t.Fatal("expected tiered settle")
|
||||
}
|
||||
// standard: p*1.5 + c*7.5 = 300000 + 7500 = 307500
|
||||
if quota != 307500 {
|
||||
t.Fatalf("quota = %d, want 307500", quota)
|
||||
}
|
||||
if result.MatchedTier != "standard" {
|
||||
t.Fatalf("tier = %s, want standard", result.MatchedTier)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTryTieredSettle_BoundaryPlusOne(t *testing.T) {
|
||||
info := makeRelayInfo(sonnetTieredExpr, 1.0, 200000, 1000)
|
||||
|
||||
// p == 200001 => crosses to long_context tier
|
||||
ok, quota, result := TryTieredSettle(info, billingexpr.TokenParams{P: 200001, C: 1000})
|
||||
if !ok {
|
||||
t.Fatal("expected tiered settle")
|
||||
}
|
||||
// long_context: p*3 + c*11.25 = 600003 + 11250 = 611253
|
||||
if quota != 611253 {
|
||||
t.Fatalf("quota = %d, want 611253", quota)
|
||||
}
|
||||
if result.MatchedTier != "long_context" {
|
||||
t.Fatalf("tier = %s, want long_context", result.MatchedTier)
|
||||
}
|
||||
if !result.CrossedTier {
|
||||
t.Fatal("expected CrossedTier = true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTryTieredSettle_ZeroTokens(t *testing.T) {
|
||||
info := makeRelayInfo(flatExpr, 1.0, 0, 0)
|
||||
|
||||
ok, quota, result := TryTieredSettle(info, billingexpr.TokenParams{P: 0, C: 0})
|
||||
if !ok {
|
||||
t.Fatal("expected tiered settle")
|
||||
}
|
||||
if quota != 0 {
|
||||
t.Fatalf("quota = %d, want 0", quota)
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("result should not be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTryTieredSettle_HugeTokens(t *testing.T) {
|
||||
info := makeRelayInfo(flatExpr, 1.0, 10000000, 5000000)
|
||||
|
||||
ok, quota, _ := TryTieredSettle(info, billingexpr.TokenParams{P: 10000000, C: 5000000})
|
||||
if !ok {
|
||||
t.Fatal("expected tiered settle")
|
||||
}
|
||||
// p*2 + c*10 = 20000000 + 50000000 = 70000000
|
||||
if quota != 70000000 {
|
||||
t.Fatalf("quota = %d, want 70000000", quota)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTryTieredSettle_CacheTokensAffectSettlement(t *testing.T) {
|
||||
info := makeRelayInfo(cacheExpr, 1.0, 1000, 500)
|
||||
|
||||
// Without cache tokens
|
||||
ok1, quota1, _ := TryTieredSettle(info, billingexpr.TokenParams{P: 1000, C: 500})
|
||||
if !ok1 {
|
||||
t.Fatal("expected tiered settle")
|
||||
}
|
||||
// p*2 + c*10 + cr*0.2 + cc*2.5 + cc1h*4 = 2000 + 5000 + 0 + 0 + 0 = 7000
|
||||
|
||||
// With cache tokens
|
||||
ok2, quota2, _ := TryTieredSettle(info, billingexpr.TokenParams{P: 1000, C: 500, CR: 10000, CC: 5000, CC1h: 2000})
|
||||
if !ok2 {
|
||||
t.Fatal("expected tiered settle")
|
||||
}
|
||||
// 2000 + 5000 + 10000*0.2 + 5000*2.5 + 2000*4 = 2000 + 5000 + 2000 + 12500 + 8000 = 29500
|
||||
|
||||
if quota2 <= quota1 {
|
||||
t.Fatalf("cache tokens should increase quota: without=%d, with=%d", quota1, quota2)
|
||||
}
|
||||
if quota1 != 7000 {
|
||||
t.Fatalf("no-cache quota = %d, want 7000", quota1)
|
||||
}
|
||||
if quota2 != 29500 {
|
||||
t.Fatalf("cache quota = %d, want 29500", quota2)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Request probe tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestTryTieredSettle_RequestProbeInfluencesBilling(t *testing.T) {
|
||||
info := makeRelayInfo(probeExpr, 1.0, 1000, 500)
|
||||
info.BillingRequestInput = &billingexpr.RequestInput{
|
||||
Body: []byte(`{"service_tier":"fast"}`),
|
||||
}
|
||||
|
||||
ok, quota, result := TryTieredSettle(info, billingexpr.TokenParams{P: 1000, C: 500})
|
||||
if !ok {
|
||||
t.Fatal("expected tiered settle")
|
||||
}
|
||||
// fast: p*4 + c*20 = 4000 + 10000 = 14000
|
||||
if quota != 14000 {
|
||||
t.Fatalf("quota = %d, want 14000", quota)
|
||||
}
|
||||
if result.MatchedTier != "fast" {
|
||||
t.Fatalf("tier = %s, want fast", result.MatchedTier)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTryTieredSettle_NoRequestInput_FallsBackToDefault(t *testing.T) {
|
||||
info := makeRelayInfo(probeExpr, 1.0, 1000, 500)
|
||||
// No BillingRequestInput set — param("service_tier") returns nil, not "fast"
|
||||
|
||||
ok, quota, result := TryTieredSettle(info, billingexpr.TokenParams{P: 1000, C: 500})
|
||||
if !ok {
|
||||
t.Fatal("expected tiered settle")
|
||||
}
|
||||
// normal: p*2 + c*10 = 2000 + 5000 = 7000
|
||||
if quota != 7000 {
|
||||
t.Fatalf("quota = %d, want 7000", quota)
|
||||
}
|
||||
if result.MatchedTier != "normal" {
|
||||
t.Fatalf("tier = %s, want normal", result.MatchedTier)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group ratio tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestTryTieredSettle_GroupRatioScaling(t *testing.T) {
|
||||
info := makeRelayInfo(flatExpr, 1.5, 1000, 500)
|
||||
|
||||
ok, quota, _ := TryTieredSettle(info, billingexpr.TokenParams{P: 1000, C: 500})
|
||||
if !ok {
|
||||
t.Fatal("expected tiered settle")
|
||||
}
|
||||
// cost = 7000, after group = round(7000 * 1.5) = 10500
|
||||
if quota != 10500 {
|
||||
t.Fatalf("quota = %d, want 10500", quota)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTryTieredSettle_GroupRatioZero(t *testing.T) {
|
||||
info := makeRelayInfo(flatExpr, 0, 1000, 500)
|
||||
|
||||
ok, quota, _ := TryTieredSettle(info, billingexpr.TokenParams{P: 1000, C: 500})
|
||||
if !ok {
|
||||
t.Fatal("expected tiered settle")
|
||||
}
|
||||
if quota != 0 {
|
||||
t.Fatalf("quota = %d, want 0 (group ratio = 0)", quota)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ratio mode (negative tests) — TryTieredSettle must return false
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestTryTieredSettle_RatioMode_NilSnapshot(t *testing.T) {
|
||||
info := &relaycommon.RelayInfo{
|
||||
TieredBillingSnapshot: nil,
|
||||
}
|
||||
|
||||
ok, _, _ := TryTieredSettle(info, billingexpr.TokenParams{P: 1000, C: 500})
|
||||
if ok {
|
||||
t.Fatal("expected TryTieredSettle to return false when snapshot is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTryTieredSettle_RatioMode_WrongBillingMode(t *testing.T) {
|
||||
info := &relaycommon.RelayInfo{
|
||||
TieredBillingSnapshot: &billingexpr.BillingSnapshot{
|
||||
BillingMode: "ratio",
|
||||
ExprString: flatExpr,
|
||||
ExprHash: billingexpr.ExprHashString(flatExpr),
|
||||
GroupRatio: 1.0,
|
||||
},
|
||||
}
|
||||
|
||||
ok, _, _ := TryTieredSettle(info, billingexpr.TokenParams{P: 1000, C: 500})
|
||||
if ok {
|
||||
t.Fatal("expected TryTieredSettle to return false for ratio billing mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTryTieredSettle_RatioMode_EmptyBillingMode(t *testing.T) {
|
||||
info := &relaycommon.RelayInfo{
|
||||
TieredBillingSnapshot: &billingexpr.BillingSnapshot{
|
||||
BillingMode: "",
|
||||
ExprString: flatExpr,
|
||||
ExprHash: billingexpr.ExprHashString(flatExpr),
|
||||
GroupRatio: 1.0,
|
||||
},
|
||||
}
|
||||
|
||||
ok, _, _ := TryTieredSettle(info, billingexpr.TokenParams{P: 1000, C: 500})
|
||||
if ok {
|
||||
t.Fatal("expected TryTieredSettle to return false for empty billing mode")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fallback tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestTryTieredSettle_ErrorFallbackToEstimatedQuotaAfterGroup(t *testing.T) {
|
||||
info := &relaycommon.RelayInfo{
|
||||
FinalPreConsumedQuota: 0,
|
||||
TieredBillingSnapshot: &billingexpr.BillingSnapshot{
|
||||
BillingMode: "tiered_expr",
|
||||
ExprString: `invalid expr!!!`,
|
||||
ExprHash: billingexpr.ExprHashString(`invalid expr!!!`),
|
||||
GroupRatio: 1.0,
|
||||
EstimatedQuotaAfterGroup: 999,
|
||||
},
|
||||
}
|
||||
|
||||
ok, quota, result := TryTieredSettle(info, billingexpr.TokenParams{P: 100})
|
||||
if !ok {
|
||||
t.Fatal("expected tiered settle to apply")
|
||||
}
|
||||
// FinalPreConsumedQuota is 0, should fall back to EstimatedQuotaAfterGroup
|
||||
if quota != 999 {
|
||||
t.Fatalf("quota = %d, want 999", quota)
|
||||
}
|
||||
if result != nil {
|
||||
t.Fatal("result should be nil on error fallback")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user