Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions controller/relay.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,12 +199,12 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
newAPIError = channelErr
break
}
addUsedChannel(c, channel.Id)
if billingErr := service.PrepareTieredBillingForSelectedGroup(c, relayInfo); billingErr != nil {
newAPIError = billingErr
break
}

addUsedChannel(c, channel.Id)
bodyStorage, bodyErr := common.GetBodyStorage(c)
if bodyErr != nil {
// Ensure consistent 413 for oversized bodies even when error occurs later (e.g., retry path)
Expand Down Expand Up @@ -312,16 +312,15 @@ func getChannel(c *gin.Context, info *relaycommon.RelayInfo, retryParam *service
}, nil
}
channel, selectGroup, err := service.CacheGetRandomSatisfiedChannel(retryParam)

info.PriceData.GroupRatioInfo = helper.HandleGroupRatio(c, info)

if err != nil {
return nil, types.NewError(fmt.Errorf("获取分组 %s 下模型 %s 的可用渠道失败(retry): %s", selectGroup, info.OriginModelName, err.Error()), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry())
}
if channel == nil {
return nil, types.NewError(fmt.Errorf("分组 %s 下模型 %s 的可用渠道不存在(retry)", selectGroup, info.OriginModelName), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry())
}

info.PriceData.GroupRatioInfo = helper.HandleGroupRatio(c, info)

newAPIError := middleware.SetupContextForSelectedChannel(c, channel, info.OriginModelName)
Comment on lines 314 to 324

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n "func HandleGroupRatio" --type go -A 40
echo "---"
rg -n "func SetupContextForSelectedChannel" --type go -A 40
echo "---"
rg -n "UsingGroup\s*=" --type go

Repository: QuantumNous/new-api

Length of output: 157


🏁 Script executed:

#!/bin/bash
set -u

echo "Go files:"
git ls-files '*.go' | wc -l

echo "--- locate candidate files by basename ---"
fd -a 'relay.go|tiered_settle.go|group|group_ratio|json.go' . | sed 's#^\./##' | head -200

echo "--- search symbols with case-insensitive text search ---"
rg -n -i 'HandleGroupRatio|SetupContextForSelectedChannel|UsingGroup|SelectGroup|selectGroup|refreshTieredBillingGroup' . | head -300

echo "--- git diff stat/summary if available ---"
git diff --stat || true

Repository: QuantumNous/new-api

Length of output: 32906


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "--- relay helper price.go ---"
cat -n relay/helper/price.go | sed -n '1,110p'

echo "--- controller/relay.go relevant section ---"
cat -n controller/relay.go | sed -n '260,350p'

echo "--- middleware auth context key setter ---"
cat -n middleware/auth.go | sed -n '455,480p'

echo "--- relay common relay_info.go BuildFromContext section ---"
cat -n relay/common/relay_info.go | sed -n '430,495p'

echo "--- middleware distributor SetupContextForSelectedChannel ---"
cat -n middleware/distributor.go | sed -n '440,490p'

echo "--- tiered settle refreshTieredBillingGroup ---"
cat -n service/tiered_settle.go | sed -n '85,105p'

Repository: QuantumNous/new-api

Length of output: 15711


Thread the selected retry group into HandleGroupRatio.

HandleGroupRatio(c, info) sets relayInfo.UsingGroup from the auto_group context value, but controller/relay.go:322 calls it before middleware.SetupContextForSelectedChannel sets that value. In the retry path, pass selectGroup to the helper or set auto_group/info.UsingGroup with the retry-selected group before computing the group ratio.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@controller/relay.go` around lines 314 - 324, The retry path computes the
group ratio before the selected retry group is available in the context, so
`HandleGroupRatio` may use the wrong group. Update the call to
`helper.HandleGroupRatio` in the retry flow to receive or otherwise use
`selectGroup`, ensuring `info.UsingGroup` reflects the retry-selected group
before `PriceData.GroupRatioInfo` is assigned.

if newAPIError != nil {
return nil, newAPIError
Expand Down
4 changes: 4 additions & 0 deletions service/billing_session.go
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,10 @@ func (s *BillingSession) preConsume(c *gin.Context, quota int) *types.NewAPIErro
func (s *BillingSession) reserveFunding(delta int) error {
switch funding := s.funding.(type) {
case *WalletFunding:
// 与结算补扣(SettleBilling 正差额 → WalletFunding.Settle)语义一致:
// 全额无条件扣减,余额不足的部分记为欠费(余额可为负),不中断请求,
// 保证日志记录的预扣额度与用户余额的实际变动始终对账一致。
// DecreaseUserQuota 仅在数据库错误时失败。
if err := model.DecreaseUserQuota(funding.userId, delta, false); err != nil {
return types.NewError(err, types.ErrorCodeUpdateDataError, types.ErrOptionWithSkipRetry())
}
Expand Down
12 changes: 11 additions & 1 deletion service/tiered_settle.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,19 @@ func PrepareTieredBillingForSelectedGroup(c *gin.Context, relayInfo *relaycommon
types.ErrOptionWithSkipRetry(),
)
}
if snap == nil || snap.GroupRatio == 0 {
if snap == nil {
return nil
}
if snap.GroupRatio == 0 {
// Paid-to-free keeps FreeModel as-is: FreeModel means "pre-consume was
// skipped", which is not true once a session exists, and settlement
// already yields 0 for a zero group ratio.
return nil
}

// The selected group is paid; clear a FreeModel flag frozen when the
// initial group was free so downstream state stays consistent.
relayInfo.PriceData.FreeModel = false

if relayInfo.Billing == nil {
return PreConsumeBilling(c, snap.EstimatedQuotaAfterGroup, relayInfo)
Expand Down
109 changes: 109 additions & 0 deletions service/tiered_settle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -389,13 +389,15 @@ func TestPrepareTieredBillingForSelectedGroupStartsBillingAfterFreeGroup(t *test
QuotaPerUnit: testQuotaPerUnit,
},
PriceData: types.PriceData{
FreeModel: true,
GroupRatioInfo: types.GroupRatioInfo{GroupRatio: 0.20},
},
}
ctx, _ := gin.CreateTestContext(nil)

require.Nil(t, PrepareTieredBillingForSelectedGroup(ctx, relayInfo))
require.NotNil(t, relayInfo.Billing)
assert.False(t, relayInfo.PriceData.FreeModel, "FreeModel must be cleared after switching to a paid group")
assert.Equal(t, 100_000, relayInfo.FinalPreConsumedQuota)
assert.Equal(t, 0.20, relayInfo.TieredBillingSnapshot.GroupRatio)
assert.Equal(t, 100_000, relayInfo.TieredBillingSnapshot.EstimatedQuotaAfterGroup)
Expand All @@ -405,6 +407,113 @@ func TestPrepareTieredBillingForSelectedGroupStartsBillingAfterFreeGroup(t *test
assert.Equal(t, 400_000, userQuota)
}

func TestPrepareTieredBillingForSelectedGroupPaidToFreeKeepsFreeModelFalse(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},
},
}

require.Nil(t, PrepareTieredBillingForSelectedGroup(nil, relayInfo))

// Pre-consume did happen under the paid group, so FreeModel stays false;
// settlement already yields 0 for GroupRatio == 0 and the session refunds.
assert.False(t, relayInfo.PriceData.FreeModel)
assert.Empty(t, billing.reserveTargets)
assert.Equal(t, 50_000, relayInfo.FinalPreConsumedQuota)
}

func TestPrepareTieredBillingForSelectedGroupTopUpArrearsAllowsNegativeBalance(t *testing.T) {
truncate(t)

const userID = 701
// Balance covers the initial 50k pre-consume (already deducted before this
// test's seed) but not the 50k top-up to the more expensive retry group.
// The top-up must NOT abort the request: the full delta is deducted, the
// uncovered 30k becomes arrears (negative balance), mirroring how
// settlement charges a positive delta unconditionally.
seedUser(t, userID, 20_000)

relayInfo := &relaycommon.RelayInfo{
UserId: userID,
IsPlayground: true,
FinalPreConsumedQuota: 50_000,
TieredBillingSnapshot: &billingexpr.BillingSnapshot{
BillingMode: "tiered_expr",
ExprString: `tier("base", p)`,
ExprHash: billingexpr.ExprHashString(`tier("base", p)`),
GroupRatio: 0.10,
EstimatedQuotaBeforeGroup: 500_000,
EstimatedQuotaAfterGroup: 50_000,
QuotaPerUnit: testQuotaPerUnit,
},
PriceData: types.PriceData{
GroupRatioInfo: types.GroupRatioInfo{GroupRatio: 0.20},
},
}
session := &BillingSession{
relayInfo: relayInfo,
funding: &WalletFunding{userId: userID, consumed: 50_000},
preConsumedQuota: 50_000,
}
relayInfo.Billing = session

require.Nil(t, PrepareTieredBillingForSelectedGroup(nil, relayInfo))

// Full reservation recorded; wallet charged the full delta into arrears.
assert.Equal(t, 100_000, session.GetPreConsumedQuota())
assert.Equal(t, 100_000, relayInfo.FinalPreConsumedQuota)
assert.Equal(t, 100_000, relayInfo.TieredBillingSnapshot.EstimatedQuotaAfterGroup)
userQuota, err := model.GetUserQuota(userID, false)
require.NoError(t, err)
assert.Equal(t, -30_000, userQuota)

// Settlement still reconciles against the full reservation: actual 80k
// refunds the 20k over-reserve, landing at seed - (actual - initial) = -10k.
require.NoError(t, session.Settle(80_000))
userQuota, err = model.GetUserQuota(userID, false)
require.NoError(t, err)
assert.Equal(t, -10_000, userQuota)
}

func TestBillingSessionReserveWalletTopUpDecrementsBalance(t *testing.T) {
truncate(t)

const userID = 702
seedUser(t, userID, 500_000)

relayInfo := &relaycommon.RelayInfo{
UserId: userID,
IsPlayground: true,
}
session := &BillingSession{
relayInfo: relayInfo,
funding: &WalletFunding{userId: userID, consumed: 50_000},
preConsumedQuota: 50_000,
}

require.NoError(t, session.Reserve(100_000))

assert.Equal(t, 100_000, session.GetPreConsumedQuota())
assert.Equal(t, 100_000, relayInfo.FinalPreConsumedQuota)
userQuota, err := model.GetUserQuota(userID, false)
require.NoError(t, err)
assert.Equal(t, 450_000, userQuota)
}

func TestTryTieredSettleUsesFinalGroupAfterRetry(t *testing.T) {
const expr = `tier("base", p)`
tests := []struct {
Expand Down
Loading