fix(billing): settle tiered retries with final group (#6518)

This commit is contained in:
吴天一
2026-07-31 19:16:46 +08:00
committed by GitHub
parent 9724ef1b24
commit df43f80153
6 changed files with 209 additions and 8 deletions
+57 -1
View File
@@ -1,9 +1,13 @@
package service
import (
"net/http"
"github.com/QuantumNous/new-api/pkg/billingexpr"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/gin-gonic/gin"
)
// TieredResultWrapper wraps billingexpr.TieredResult for use at the service layer.
@@ -90,8 +94,60 @@ func BuildTieredTokenParams(usage *dto.Usage, isClaudeUsageSemantic bool, usedVa
}
}
func refreshTieredBillingGroup(relayInfo *relaycommon.RelayInfo) (*billingexpr.BillingSnapshot, error) {
if relayInfo == nil {
return nil, nil
}
snap := relayInfo.TieredBillingSnapshot
if snap == nil || snap.BillingMode != "tiered_expr" {
return nil, nil
}
groupRatio := relayInfo.PriceData.GroupRatioInfo.GroupRatio
if snap.GroupRatio == groupRatio {
return snap, nil
}
estimatedQuotaAfterGroup := snap.EstimatedQuotaBeforeGroup * groupRatio
estimatedQuota, err := billingexpr.QuotaRoundStrict(estimatedQuotaAfterGroup)
if err != nil {
return nil, err
}
snap.GroupRatio = groupRatio
snap.EstimatedQuotaAfterGroup = estimatedQuota
return snap, nil
}
// PrepareTieredBillingForSelectedGroup refreshes routing-dependent billing
// state before an upstream attempt. An existing session reserves any higher
// estimate before sending. If the initial group was free and skipped
// pre-consume, switching to a paid group creates the session at that point.
func PrepareTieredBillingForSelectedGroup(c *gin.Context, relayInfo *relaycommon.RelayInfo) *types.NewAPIError {
snap, err := refreshTieredBillingGroup(relayInfo)
if err != nil {
return types.NewErrorWithStatusCode(
err,
types.ErrorCodeModelPriceError,
http.StatusBadRequest,
types.ErrOptionWithSkipRetry(),
)
}
if snap == nil || snap.GroupRatio == 0 {
return nil
}
if relayInfo.Billing == nil {
return PreConsumeBilling(c, snap.EstimatedQuotaAfterGroup, relayInfo)
}
if err := relayInfo.Billing.Reserve(snap.EstimatedQuotaAfterGroup); err != nil {
return types.NewError(err, types.ErrorCodeUpdateDataError, types.ErrOptionWithSkipRetry())
}
relayInfo.FinalPreConsumedQuota = relayInfo.Billing.GetPreConsumedQuota()
return nil
}
// TryTieredSettle checks if the request uses tiered_expr billing and, if so,
// computes the actual quota using the frozen BillingSnapshot. Returns:
// computes the actual quota using the captured 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) {
+138
View File
@@ -5,10 +5,15 @@ import (
"math/rand"
"testing"
"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/relaykit/dto"
"github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin"
"github.com/shopspring/decimal"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Claude Sonnet-style tiered expression: standard vs long-context
@@ -309,6 +314,139 @@ func TestTryTieredSettle_NoRequestInput_FallsBackToDefault(t *testing.T) {
// Group ratio tests
// ---------------------------------------------------------------------------
type recordingBillingSettler struct {
preConsumedQuota int
reserveTargets []int
}
func (*recordingBillingSettler) Settle(int) error { return nil }
func (*recordingBillingSettler) Refund(*gin.Context) {}
func (*recordingBillingSettler) NeedsRefund() bool { return false }
func (s *recordingBillingSettler) GetPreConsumedQuota() int {
return s.preConsumedQuota
}
func (s *recordingBillingSettler) Reserve(targetQuota int) error {
s.reserveTargets = append(s.reserveTargets, targetQuota)
if targetQuota > s.preConsumedQuota {
s.preConsumedQuota = targetQuota
}
return nil
}
func TestPrepareTieredBillingForSelectedGroupUpdatesReservation(t *testing.T) {
const expr = `tier("base", p)`
billing := &recordingBillingSettler{preConsumedQuota: 50_000}
relayInfo := &relaycommon.RelayInfo{
Billing: billing,
FinalPreConsumedQuota: 50_000,
TieredBillingSnapshot: &billingexpr.BillingSnapshot{
BillingMode: "tiered_expr",
ExprString: expr,
ExprHash: billingexpr.ExprHashString(expr),
GroupRatio: 0.10,
EstimatedQuotaBeforeGroup: 500_000,
EstimatedQuotaAfterGroup: 50_000,
QuotaPerUnit: testQuotaPerUnit,
},
PriceData: types.PriceData{
GroupRatioInfo: types.GroupRatioInfo{GroupRatio: 0.20},
},
}
require.Nil(t, PrepareTieredBillingForSelectedGroup(nil, relayInfo))
require.Equal(t, []int{100_000}, billing.reserveTargets)
assert.Equal(t, 100_000, billing.preConsumedQuota)
assert.Equal(t, 100_000, relayInfo.FinalPreConsumedQuota)
assert.Equal(t, 0.20, relayInfo.TieredBillingSnapshot.GroupRatio)
assert.Equal(t, 100_000, relayInfo.TieredBillingSnapshot.EstimatedQuotaAfterGroup)
}
func TestPrepareTieredBillingForSelectedGroupStartsBillingAfterFreeGroup(t *testing.T) {
truncate(t)
gin.SetMode(gin.TestMode)
const userID = 700
seedUser(t, userID, 500_000)
relayInfo := &relaycommon.RelayInfo{
UserId: userID,
IsPlayground: true,
ForcePreConsume: true,
OriginModelName: "gpt-test",
UserSetting: dto.UserSetting{
BillingPreference: "wallet_only",
},
TieredBillingSnapshot: &billingexpr.BillingSnapshot{
BillingMode: "tiered_expr",
ExprString: `tier("base", p)`,
ExprHash: billingexpr.ExprHashString(`tier("base", p)`),
GroupRatio: 0,
EstimatedQuotaBeforeGroup: 500_000,
QuotaPerUnit: testQuotaPerUnit,
},
PriceData: types.PriceData{
GroupRatioInfo: types.GroupRatioInfo{GroupRatio: 0.20},
},
}
ctx, _ := gin.CreateTestContext(nil)
require.Nil(t, PrepareTieredBillingForSelectedGroup(ctx, relayInfo))
require.NotNil(t, relayInfo.Billing)
assert.Equal(t, 100_000, relayInfo.FinalPreConsumedQuota)
assert.Equal(t, 0.20, relayInfo.TieredBillingSnapshot.GroupRatio)
assert.Equal(t, 100_000, relayInfo.TieredBillingSnapshot.EstimatedQuotaAfterGroup)
userQuota, err := model.GetUserQuota(userID, false)
require.NoError(t, err)
assert.Equal(t, 400_000, userQuota)
}
func TestTryTieredSettleUsesFinalGroupAfterRetry(t *testing.T) {
const expr = `tier("base", p)`
tests := []struct {
name string
finalGroupRatio float64
wantQuota int
}{
{name: "more expensive final group", finalGroupRatio: 0.20, wantQuota: 100_000},
{name: "free final group", finalGroupRatio: 0, wantQuota: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
relayInfo := &relaycommon.RelayInfo{
Billing: &recordingBillingSettler{preConsumedQuota: 50_000},
FinalPreConsumedQuota: 50_000,
TieredBillingSnapshot: &billingexpr.BillingSnapshot{
BillingMode: "tiered_expr",
ExprString: expr,
ExprHash: billingexpr.ExprHashString(expr),
GroupRatio: 0.10,
EstimatedQuotaBeforeGroup: 500_000,
EstimatedQuotaAfterGroup: 50_000,
QuotaPerUnit: testQuotaPerUnit,
},
PriceData: types.PriceData{
GroupRatioInfo: types.GroupRatioInfo{GroupRatio: tt.finalGroupRatio},
},
}
require.Nil(t, PrepareTieredBillingForSelectedGroup(nil, relayInfo))
ok, quota, result := TryTieredSettle(relayInfo, billingexpr.TokenParams{P: 1_000_000})
require.True(t, ok)
require.NotNil(t, result)
assert.Equal(t, tt.wantQuota, quota)
assert.Equal(t, tt.finalGroupRatio, relayInfo.TieredBillingSnapshot.GroupRatio)
assert.Equal(t, tt.wantQuota, relayInfo.TieredBillingSnapshot.EstimatedQuotaAfterGroup)
})
}
}
func TestTryTieredSettle_GroupRatioScaling(t *testing.T) {
info := makeRelayInfo(flatExpr, 1.5, 1000, 500)