fix(billing): surface quota saturation events for admin auditing

Thread int32 saturation clamps from tiered settlement and video task
recompute into the consume/task logs under admin_info, so oversized or
malformed billing inputs stay auditable. Clamp negative audio duration
before token conversion and gate the saturation UI markers on admin.
This commit is contained in:
CaIon
2026-07-07 12:20:07 +08:00
parent c9943d37ad
commit bae799ccb1
32 changed files with 710 additions and 104 deletions
+35
View File
@@ -2,11 +2,13 @@ package service
import (
"encoding/base64"
"fmt"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/pkg/billingexpr"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/types"
@@ -14,6 +16,39 @@ import (
"github.com/gin-gonic/gin"
)
// attachQuotaSaturationToOther nests a quota saturation marker under
// other.admin_info.quota_saturation. Nesting under admin_info makes it
// admin-only for free, since model.formatUserLogs strips the whole admin_info
// object for non-admin viewers. Creates admin_info if absent. No-op when the
// clamp is nil (the common case: no saturation happened).
func attachQuotaSaturationToOther(other map[string]interface{}, clamp *common.QuotaClamp) {
if clamp == nil || other == nil {
return
}
adminInfo, ok := other["admin_info"].(map[string]interface{})
if !ok || adminInfo == nil {
adminInfo = map[string]interface{}{}
other["admin_info"] = adminInfo
}
adminInfo["quota_saturation"] = clamp.AuditMap()
}
// attachQuotaSaturation records the request's quota clamp (if any) onto the
// consume log's other.admin_info and emits a request-correlated backend audit
// line. Called right before RecordConsumeLog on the text/audio/wss paths.
func attachQuotaSaturation(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, other map[string]interface{}) {
if relayInfo == nil {
return
}
clamp := relayInfo.QuotaClamp
if clamp == nil {
return
}
attachQuotaSaturationToOther(other, clamp)
logger.LogWarn(ctx, fmt.Sprintf("quota saturation on consume log: op=%s kind=%s original=%g clamped=%d user=%d model=%s",
clamp.Op, clamp.Kind, clamp.Original, clamp.Clamped, relayInfo.UserId, relayInfo.OriginModelName))
}
func appendRequestPath(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, other map[string]interface{}) {
if other == nil {
return
+11 -6
View File
@@ -47,14 +47,14 @@ func hasCustomModelRatio(modelName string, currentRatio float64) bool {
return currentRatio != defaultRatio
}
func calculateAudioQuota(info QuotaInfo) int {
func calculateAudioQuota(info QuotaInfo) (int, *common.QuotaClamp) {
if info.UsePrice {
modelPrice := decimal.NewFromFloat(info.ModelPrice)
quotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
groupRatio := decimal.NewFromFloat(info.GroupRatio)
quota := modelPrice.Mul(quotaPerUnit).Mul(groupRatio)
return decimalToQuota(quota)
return common.QuotaFromDecimalChecked(quota)
}
completionRatio := decimal.NewFromFloat(ratio_setting.GetCompletionRatio(info.ModelName))
@@ -83,7 +83,7 @@ func calculateAudioQuota(info QuotaInfo) int {
quota = decimal.NewFromInt(1)
}
return decimalToQuota(quota)
return common.QuotaFromDecimalChecked(quota)
}
func PreWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.RealtimeUsage) error {
@@ -136,7 +136,8 @@ func PreWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usag
GroupRatio: actualGroupRatio,
}
quota := calculateAudioQuota(quotaInfo)
quota, clamp := calculateAudioQuota(quotaInfo)
noteQuotaClamp(relayInfo, clamp)
if userQuota < quota {
return fmt.Errorf("user quota is not enough, user quota: %s, need quota: %s", logger.FormatQuota(userQuota), logger.FormatQuota(quota))
@@ -199,7 +200,8 @@ func PostWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, mod
GroupRatio: groupRatio,
}
quota := calculateAudioQuota(quotaInfo)
quota, clamp := calculateAudioQuota(quotaInfo)
noteQuotaClamp(relayInfo, clamp)
if tieredOk {
quota = tieredQuota
}
@@ -239,6 +241,7 @@ func PostWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, mod
if tieredResult != nil {
InjectTieredBillingInfo(other, relayInfo, tieredResult)
}
attachQuotaSaturation(ctx, relayInfo, other)
model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{
ChannelId: relayInfo.ChannelId,
PromptTokens: usage.InputTokens,
@@ -320,7 +323,8 @@ func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, u
GroupRatio: groupRatio,
}
quota := calculateAudioQuota(quotaInfo)
quota, clamp := calculateAudioQuota(quotaInfo)
noteQuotaClamp(relayInfo, clamp)
if tieredOk {
quota = tieredQuota
}
@@ -360,6 +364,7 @@ func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, u
if tieredResult != nil {
InjectTieredBillingInfo(other, relayInfo, tieredResult)
}
attachQuotaSaturation(ctx, relayInfo, other)
model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{
ChannelId: relayInfo.ChannelId,
PromptTokens: usage.PromptTokens,
+74
View File
@@ -0,0 +1,74 @@
package service
import (
"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"
)
// TestAttachQuotaSaturationNestsUnderAdminInfo verifies the saturation marker
// is nested under other.admin_info.quota_saturation so it is admin-only (the
// log formatter strips admin_info for non-admin viewers).
func TestAttachQuotaSaturationNestsUnderAdminInfo(t *testing.T) {
gin.SetMode(gin.TestMode)
ctx, _ := gin.CreateTestContext(nil)
relayInfo := &relaycommon.RelayInfo{
UserId: 7,
OriginModelName: "gpt-image-1",
QuotaClamp: &common.QuotaClamp{
Op: "QuotaFromDecimal",
Kind: common.QuotaClampOverflow,
Original: 1.8e19,
Clamped: common.MaxQuota,
},
}
other := map[string]interface{}{"model_price": 0.004}
attachQuotaSaturation(ctx, relayInfo, other)
adminInfo, ok := other["admin_info"].(map[string]interface{})
require.True(t, ok, "admin_info should be created")
sat, ok := adminInfo["quota_saturation"].(map[string]interface{})
require.True(t, ok, "quota_saturation should be nested under admin_info")
require.Equal(t, "QuotaFromDecimal", sat["op"])
require.Equal(t, common.QuotaClampOverflow, sat["kind"])
require.Equal(t, common.MaxQuota, sat["clamped"])
}
// TestAttachQuotaSaturationPreservesExistingAdminInfo verifies the marker is
// merged into a pre-existing admin_info map without clobbering it.
func TestAttachQuotaSaturationPreservesExistingAdminInfo(t *testing.T) {
gin.SetMode(gin.TestMode)
ctx, _ := gin.CreateTestContext(nil)
relayInfo := &relaycommon.RelayInfo{
QuotaClamp: &common.QuotaClamp{Op: "QuotaFromFloat", Kind: common.QuotaClampUnderflow, Clamped: common.MinQuota},
}
other := map[string]interface{}{
"admin_info": map[string]interface{}{"admin_username": "root"},
}
attachQuotaSaturation(ctx, relayInfo, other)
adminInfo := other["admin_info"].(map[string]interface{})
require.Equal(t, "root", adminInfo["admin_username"], "existing admin_info fields preserved")
require.NotNil(t, adminInfo["quota_saturation"])
}
// TestAttachQuotaSaturationNoClampNoMarker verifies the common case (no
// saturation) leaves the log untouched.
func TestAttachQuotaSaturationNoClampNoMarker(t *testing.T) {
gin.SetMode(gin.TestMode)
ctx, _ := gin.CreateTestContext(nil)
relayInfo := &relaycommon.RelayInfo{QuotaClamp: nil}
other := map[string]interface{}{"model_price": 0.004}
attachQuotaSaturation(ctx, relayInfo, other)
_, hasAdmin := other["admin_info"]
require.False(t, hasAdmin, "no admin_info should be added when there is no clamp")
}
+8 -3
View File
@@ -50,6 +50,7 @@ func LogTaskConsumption(c *gin.Context, info *relaycommon.RelayInfo) {
other["is_model_mapped"] = true
other["upstream_model_name"] = info.UpstreamModelName
}
attachQuotaSaturation(c, info, other)
model.RecordConsumeLog(c, info.UserId, model.RecordConsumeLogParams{
ChannelId: info.ChannelId,
ModelName: info.OriginModelName,
@@ -184,7 +185,8 @@ func RefundTaskQuota(ctx context.Context, task *model.Task, reason string) {
// RecalculateTaskQuota 通用的异步差额结算。
// actualQuota 是任务完成后的实际应扣额度,与预扣额度 (task.Quota) 做差额结算。
// reason 用于日志记录(例如 "token重算" 或 "adaptor调整")。
func RecalculateTaskQuota(ctx context.Context, task *model.Task, actualQuota int, reason string) {
// clamps 可选:若计算 actualQuota 时发生额度饱和,将其记入日志 admin_info(仅管理员可见)。
func RecalculateTaskQuota(ctx context.Context, task *model.Task, actualQuota int, reason string, clamps ...*common.QuotaClamp) {
if actualQuota <= 0 {
return
}
@@ -234,6 +236,9 @@ func RecalculateTaskQuota(ctx context.Context, task *model.Task, actualQuota int
other["task_id"] = task.TaskID
other["pre_consumed_quota"] = preConsumedQuota
other["actual_quota"] = actualQuota
for _, clamp := range clamps {
attachQuotaSaturationToOther(other, clamp)
}
model.RecordTaskBillingLog(model.RecordTaskBillingLogParams{
UserId: task.UserId,
LogType: logType,
@@ -298,8 +303,8 @@ func RecalculateTaskQuotaByTokens(ctx context.Context, task *model.Task, totalTo
}
// 计算实际应扣费额度: totalTokens * modelRatio * groupRatio * otherMultiplier(饱和转换,防止溢出成负数)
actualQuota := common.QuotaFromFloat(float64(totalTokens) * modelRatio * finalGroupRatio * otherMultiplier)
actualQuota, clamp := common.QuotaFromFloatChecked(float64(totalTokens) * modelRatio * finalGroupRatio * otherMultiplier)
reason := fmt.Sprintf("token重算:tokens=%d, modelRatio=%.2f, groupRatio=%.2f, otherMultiplier=%.4f", totalTokens, modelRatio, finalGroupRatio, otherMultiplier)
RecalculateTaskQuota(ctx, task, actualQuota, reason)
RecalculateTaskQuota(ctx, task, actualQuota, reason, clamp)
}
+26 -12
View File
@@ -138,6 +138,18 @@ func calculateTextToolCallSurcharge(ctx *gin.Context, relayInfo *relaycommon.Rel
return surcharge
}
// noteQuotaClamp records the first quota saturation event onto relayInfo so it
// can later be attached to the consume/task log for admin auditing. First
// non-nil clamp wins (a single request may hit multiple conversions).
func noteQuotaClamp(relayInfo *relaycommon.RelayInfo, clamp *common.QuotaClamp) {
if clamp == nil || relayInfo == nil {
return
}
if relayInfo.QuotaClamp == nil {
relayInfo.QuotaClamp = clamp
}
}
func composeTieredTextQuota(relayInfo *relaycommon.RelayInfo, summary textQuotaSummary, tieredQuota int, tieredResult *billingexpr.TieredResult) int {
if summary.ToolCallSurchargeQuota.IsZero() {
return tieredQuota
@@ -145,13 +157,17 @@ func composeTieredTextQuota(relayInfo *relaycommon.RelayInfo, summary textQuotaS
if tieredResult != nil {
if snap := relayInfo.TieredBillingSnapshot; snap != nil {
return decimalToQuota(decimal.NewFromFloat(tieredResult.ActualQuotaBeforeGroup).
quota, clamp := common.QuotaFromDecimalChecked(decimal.NewFromFloat(tieredResult.ActualQuotaBeforeGroup).
Mul(decimal.NewFromFloat(snap.GroupRatio)).
Add(summary.ToolCallSurchargeQuota))
noteQuotaClamp(relayInfo, clamp)
return quota
}
}
return tieredQuota + decimalToQuota(summary.ToolCallSurchargeQuota)
surcharge, clamp := common.QuotaFromDecimalChecked(summary.ToolCallSurchargeQuota)
noteQuotaClamp(relayInfo, clamp)
return tieredQuota + surcharge
}
func calculateTextQuotaSummary(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage) textQuotaSummary {
@@ -285,7 +301,9 @@ func calculateTextQuotaSummary(ctx *gin.Context, relayInfo *relaycommon.RelayInf
if !ratio.IsZero() && quotaCalculateDecimal.LessThanOrEqual(decimal.Zero) {
quotaCalculateDecimal = decimal.NewFromInt(1)
}
summary.Quota = decimalToQuota(quotaCalculateDecimal)
quota, clamp := common.QuotaFromDecimalChecked(quotaCalculateDecimal)
summary.Quota = quota
noteQuotaClamp(relayInfo, clamp)
} else {
quotaCalculateDecimal := dModelPrice.Mul(dQuotaPerUnit).Mul(dGroupRatio)
quotaCalculateDecimal = quotaCalculateDecimal.Add(summary.ToolCallSurchargeQuota)
@@ -295,7 +313,9 @@ func calculateTextQuotaSummary(ctx *gin.Context, relayInfo *relaycommon.RelayInf
quotaCalculateDecimal = quotaCalculateDecimal.Mul(decimal.NewFromFloat(otherRatio))
}
}
summary.Quota = decimalToQuota(quotaCalculateDecimal)
quota, clamp := common.QuotaFromDecimalChecked(quotaCalculateDecimal)
summary.Quota = quota
noteQuotaClamp(relayInfo, clamp)
}
if summary.TotalTokens == 0 {
@@ -307,14 +327,6 @@ func calculateTextQuotaSummary(ctx *gin.Context, relayInfo *relaycommon.RelayInf
return summary
}
// decimalToQuota converts a computed quota decimal to int with saturation
// (see common.QuotaFromFloat). Oversized multipliers (e.g. an absurd image
// generation count) must never wrap around and turn a charge into a credit.
func decimalToQuota(d decimal.Decimal) int {
f, _ := d.Round(0).Float64()
return common.QuotaFromFloat(f)
}
func usageSemanticFromUsage(relayInfo *relaycommon.RelayInfo, usage *dto.Usage) string {
if usage != nil && usage.UsageSemantic != "" {
return usage.UsageSemantic
@@ -465,6 +477,8 @@ func PostTextConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, us
InjectTieredBillingInfo(other, relayInfo, tieredResult)
}
attachQuotaSaturation(ctx, relayInfo, other)
model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{
ChannelId: relayInfo.ChannelId,
PromptTokens: summary.PromptTokens,
+50 -13
View File
@@ -6,6 +6,7 @@ import (
"testing"
"time"
"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"
@@ -13,22 +14,9 @@ import (
"github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin"
"github.com/shopspring/decimal"
"github.com/stretchr/testify/require"
)
// TestDecimalToQuotaSaturation guards the billing invariant that an oversized
// quota product (e.g. per-call price multiplied by a huge image n ratio) must
// saturate instead of wrapping into a negative charge (credit).
func TestDecimalToQuotaSaturation(t *testing.T) {
// 2000 quota per call * n=18446744073686646784 overflows int64.
overflowing := decimal.NewFromInt(2000).Mul(decimal.NewFromFloat(1.8446744073686647e19))
require.Equal(t, math.MaxInt32, decimalToQuota(overflowing))
require.Equal(t, math.MinInt32, decimalToQuota(overflowing.Neg()))
require.Equal(t, 42, decimalToQuota(decimal.NewFromFloat(41.7)))
}
func TestCalculateTextQuotaSummaryUnifiedForClaudeSemantic(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
@@ -453,3 +441,52 @@ func TestComposeTieredTextQuotaErrorFallbackUsesPreConsumedQuota(t *testing.T) {
require.Equal(t, int64(12500), summary.ToolCallSurchargeQuota.Round(0).IntPart())
require.Equal(t, 14500, quota)
}
// TestTryTieredSettleRecordsClampOnOverflow guards that an oversized tiered
// settlement both saturates the quota and records the clamp on RelayInfo, so
// every consume path (text, audio, WSS) can surface it under admin_info.
func TestTryTieredSettleRecordsClampOnOverflow(t *testing.T) {
// exprOutput = p * 1e9; quotaBeforeGroup = p*1e9 / 1e6 * 5e5 far exceeds
// MaxInt32 and must saturate.
exprStr := `tier("base", p * 1000000000)`
relayInfo := &relaycommon.RelayInfo{
OriginModelName: "overflow-model",
TieredBillingSnapshot: &billingexpr.BillingSnapshot{
BillingMode: "tiered_expr",
ExprString: exprStr,
ExprHash: billingexpr.ExprHashString(exprStr),
GroupRatio: 1,
QuotaPerUnit: 500_000,
},
}
ok, quota, result := TryTieredSettle(relayInfo, billingexpr.TokenParams{P: 1_000_000_000})
require.True(t, ok)
require.NotNil(t, result)
require.Equal(t, math.MaxInt32, quota, "oversized settlement must clamp, never wrap negative")
require.NotNil(t, relayInfo.QuotaClamp, "clamp must be recorded on RelayInfo for admin auditing")
require.Equal(t, common.QuotaClampOverflow, relayInfo.QuotaClamp.Kind)
}
// TestTryTieredSettleNoClampInRange confirms an in-range settlement leaves
// RelayInfo.QuotaClamp nil.
func TestTryTieredSettleNoClampInRange(t *testing.T) {
exprStr := `tier("base", p * 2 + c * 10)`
relayInfo := &relaycommon.RelayInfo{
OriginModelName: "in-range-model",
TieredBillingSnapshot: &billingexpr.BillingSnapshot{
BillingMode: "tiered_expr",
ExprString: exprStr,
ExprHash: billingexpr.ExprHashString(exprStr),
GroupRatio: 1,
QuotaPerUnit: 500_000,
},
}
ok, _, result := TryTieredSettle(relayInfo, billingexpr.TokenParams{P: 1000, C: 500})
require.True(t, ok)
require.NotNil(t, result)
require.Nil(t, relayInfo.QuotaClamp, "in-range settlement must not record a clamp")
}
+5
View File
@@ -112,5 +112,10 @@ func TryTieredSettle(relayInfo *relaycommon.RelayInfo, params billingexpr.TokenP
return true, quota, nil
}
// Surface any int32 saturation from settlement onto RelayInfo so the
// consume log records it under admin_info, regardless of which caller
// (text, audio, WSS) consumes the returned quota. First non-nil wins.
noteQuotaClamp(relayInfo, tr.Clamp)
return true, tr.ActualQuotaAfterGroup, &tr
}
+6 -7
View File
@@ -208,14 +208,13 @@ func EstimateRequestToken(c *gin.Context, meta *types.TokenCountMeta, info *rela
if err != nil {
return 0, fmt.Errorf("error getting audio duration: %v", err)
}
// 一分钟 1000 token,与 $price / minute 对齐
// duration 来自用户上传文件的元数据,可被伪造成天文数字,
// 必须饱和转换防止 int 回绕成负数 token。
audioTokens := common.QuotaFromFloat(math.Round(math.Ceil(duration) / 60.0 * 1000))
if audioTokens < 0 {
audioTokens = 0
// duration 来自用户上传文件的元数据,可被伪造成天文数字或负数
// 负值会让 token 估算变成负数(低估预扣费),先钳到 0 再转换。
if duration < 0 {
duration = 0
}
totalAudioToken += audioTokens
// 一分钟 1000 token,与 $price / minute 对齐。
totalAudioToken += common.QuotaRound(math.Ceil(duration) / 60.0 * 1000)
}
return totalAudioToken, nil
}
+2 -4
View File
@@ -1,8 +1,6 @@
package service
import (
"math"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/setting/operation_setting"
)
@@ -49,7 +47,7 @@ func ComputeToolCallQuota(usage ToolCallUsage, groupRatio float64) ToolCallResul
return
}
totalPrice := pricePer1K * float64(count) / 1000
quota := common.QuotaFromFloat(math.Round(totalPrice * common.QuotaPerUnit * groupRatio))
quota := common.QuotaRound(totalPrice * common.QuotaPerUnit * groupRatio)
items = append(items, ToolCallItem{
Name: toolName,
CallCount: count,
@@ -70,7 +68,7 @@ func ComputeToolCallQuota(usage ToolCallUsage, groupRatio float64) ToolCallResul
if usage.ImageGenerationCall {
price := operation_setting.GetGPTImage1PriceOnceCall(usage.ImageGenerationQuality, usage.ImageGenerationSize)
quota := common.QuotaFromFloat(math.Round(price * common.QuotaPerUnit * groupRatio))
quota := common.QuotaRound(price * common.QuotaPerUnit * groupRatio)
items = append(items, ToolCallItem{
Name: "image_generation",
CallCount: 1,