diff --git a/AGENTS.md b/AGENTS.md index aea1a633..cbd781b2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -103,7 +103,8 @@ Do NOT directly import or call `encoding/json` in business code. `json.RawMessag - Every user-controlled quantity that becomes a billing multiplier (image `n`, video `seconds`/`duration`, resolution/quality ratios, batch counts) MUST be bounded before it reaches quota calculation. Reject out-of-range values at request validation with a 400. Existing bounds: `dto.MaxImageN` for image generation count, `relaycommon.MaxTaskDurationSeconds` for task video duration, `maxTokensLimit` (`relay/helper/valid_request.go`) for `max_tokens`-family fields on every relay format (OpenAI, Claude, Gemini, Responses). Reuse these constants instead of introducing new ad hoc limits for the same concepts. When adding a new relay format or request DTO, bound its max-tokens and count fields in its validator from day one. - Watch for validation bypass paths: passthrough fields (e.g. `Extra["parameters"]`), task `metadata` maps, and multipart form fields can carry the same quantities around the standard DTO validation. Any adaptor that reads a multiplier from such a path must enforce the same bound (or clamp) locally. - Durations parsed from media metadata are user/upstream-controlled too: audio file headers (transcription token counting, TTS response duration) and upstream deduction numbers (e.g. Kling `FinalUnitDeduction`) can claim absurd values. Convert them with saturation before they become token counts. -- Never convert a computed quota or token count to `int` with a bare cast like `int(float64(quota) * ratio)`, `int(math.Round(...))` on unbounded input, or `int(decimal.IntPart())`. Use the saturating converters: `common.QuotaFromFloat` for float products, `decimalToQuota` in `service` for decimal products, `billingexpr.QuotaRound` (saturating) for tiered-expression results. Saturation bounds are int32 because quota columns (user/token/log) are 32-bit integers in the database. +- Never convert a computed quota or token count to `int` with a bare cast like `int(float64(quota) * ratio)`, `int(math.Round(...))` on unbounded input, or `int(decimal.IntPart())`. All quota rounding/conversion is centralized in `common/quota_math.go`; use those helpers: `common.QuotaFromFloat` (truncating) for float products, `common.QuotaRound` (half-away-from-zero) where rounding is intended, and `common.QuotaFromDecimal` for decimal products. `billingexpr.QuotaRound` delegates to `common.QuotaRound`. Do not reintroduce local conversion helpers or bare casts. Saturation bounds are int32 because quota columns (user/token/log) are 32-bit integers in the database, and every clamp/NaN fallback is logged via `common.SysError` since a single request should never approach those bounds. +- Saturation events are also audited: each helper has a `*Checked` variant (`common.QuotaFromFloatChecked` / `QuotaRoundChecked` / `QuotaFromDecimalChecked`) that additionally returns a `*common.QuotaClamp` when clamping occurred. Billing paths that compute a charge capture that clamp onto `relayInfo.QuotaClamp` (or thread it into task settlement) and, right before writing the consume/task log, call `attachQuotaSaturation` (in `service/log_info_generate.go`) which nests the marker under the log's `other.admin_info.quota_saturation` and emits a request-correlated `logger.LogWarn`. Nesting under `admin_info` makes it admin-only for free (non-admin log views strip `admin_info`). When adding a new billing path, use the `*Checked` variant and surface the clamp the same way so the anomaly stays auditable in both the admin log UI and backend logs. - Multiplier maps go through `types.PriceData.AddOtherRatio`, which rejects non-positive, NaN, and +Inf ratios. Do not write to `PriceData.OtherRatios` directly, and do not weaken these guards. - Pre-consume (预扣费) and settle (结算/差额) must both be safe: a saturated oversized quota must fail pre-consume with insufficient-quota, never silently wrap. When adding a new billing path (new relay format, new task platform, new adjustment hook), trace the full chain — validation → EstimateBilling/OtherRatios → quota conversion → pre-consume → settle/refund — and confirm each step preserves these invariants. - Fields parsed into unsigned types (`*uint`) accept huge positive JSON numbers (e.g. `18446744073686646784`, a wrapped negative); a `>= 0` check is not sufficient, an upper bound is mandatory. diff --git a/common/quota_math.go b/common/quota_math.go index 878b3526..1a5a5ac7 100644 --- a/common/quota_math.go +++ b/common/quota_math.go @@ -1,21 +1,117 @@ package common -import "math" +import ( + "fmt" + "math" -// QuotaFromFloat converts a computed quota value to int with saturation. -// Quota products can include user-controlled multipliers (image n, video -// seconds, resolution ratios); an oversized product must never wrap around -// and turn a charge into a credit. The bound is int32 because quota columns -// (user/token/log) are 32-bit integers in the database. -func QuotaFromFloat(value float64) int { - if math.IsNaN(value) { - return 0 + "github.com/shopspring/decimal" +) + +// Quota conversions are centralized here so every billing path shares one +// saturation + logging policy. Quota columns (user/token/log) are 32-bit +// integers in the database, so an oversized product must clamp to the int32 +// range instead of wrapping around and turning a charge into a credit. +const ( + MaxQuota = math.MaxInt32 + MinQuota = math.MinInt32 +) + +// Clamp kinds reported by QuotaClamp.Kind. +const ( + QuotaClampOverflow = "overflow" + QuotaClampUnderflow = "underflow" + QuotaClampNaN = "nan" +) + +// QuotaClamp describes a single saturation event: a quota conversion whose +// input fell outside the representable int32 range (or was NaN) and was +// therefore clamped. It is surfaced to billing callers so the event can be +// recorded on the related consume/task log for admin auditing. +type QuotaClamp struct { + Op string `json:"op"` // "QuotaFromFloat" | "QuotaRound" | "QuotaFromDecimal" + Kind string `json:"kind"` // "overflow" | "underflow" | "nan" + Original float64 `json:"original"` // best-effort pre-clamp value (decimal -> float64 approx) + Clamped int `json:"clamped"` // the saturated result actually used +} + +// AuditMap renders the clamp as the marker stored under a log's +// admin_info.quota_saturation. Centralized here so every billing path (consume +// logs, task billing logs, task compensation logs) records the same shape. +func (c *QuotaClamp) AuditMap() map[string]interface{} { + if c == nil { + return nil } - if value >= math.MaxInt32 { - return math.MaxInt32 + return map[string]interface{}{ + "op": c.Op, + "kind": c.Kind, + "original": c.Original, + "clamped": c.Clamped, } - if value <= math.MinInt32 { - return math.MinInt32 +} + +// saturateQuota converts an already-rounded quota value to int, clamping to +// the int32 range. Whenever clamping (what would otherwise be an integer +// wraparound) or a NaN fallback is triggered it logs a warning, because in +// normal operation a single request never approaches these bounds — hitting +// them signals a bug or an abusive request. `op` names the caller. When a +// clamp occurs it returns a non-nil *QuotaClamp so callers can additionally +// record the event (e.g. on the consume log); the returned pointer is nil for +// in-range values. +func saturateQuota(value float64, op string) (int, *QuotaClamp) { + switch { + case math.IsNaN(value): + SysError(fmt.Sprintf("quota conversion (%s) received NaN, falling back to 0", op)) + return 0, &QuotaClamp{Op: op, Kind: QuotaClampNaN, Original: value, Clamped: 0} + case value >= MaxQuota: + SysError(fmt.Sprintf("quota conversion (%s) overflow: %g exceeds max quota, clamped to %d", op, value, MaxQuota)) + return MaxQuota, &QuotaClamp{Op: op, Kind: QuotaClampOverflow, Original: value, Clamped: MaxQuota} + case value <= MinQuota: + SysError(fmt.Sprintf("quota conversion (%s) underflow: %g below min quota, clamped to %d", op, value, MinQuota)) + return MinQuota, &QuotaClamp{Op: op, Kind: QuotaClampUnderflow, Original: value, Clamped: MinQuota} + default: + return int(value), nil } - return int(value) +} + +// QuotaFromFloat converts a computed quota value to int, truncating toward +// zero, with saturation. Use for float products of prices, ratios, and +// user-controlled multipliers (image n, video seconds, resolution ratios). +func QuotaFromFloat(value float64) int { + quota, _ := QuotaFromFloatChecked(value) + return quota +} + +// QuotaFromFloatChecked is QuotaFromFloat but also returns a non-nil +// *QuotaClamp when the value was clamped, so billing callers can audit it. +func QuotaFromFloatChecked(value float64) (int, *QuotaClamp) { + return saturateQuota(value, "QuotaFromFloat") +} + +// QuotaRound converts a float64 quota value to int using half-away-from-zero +// rounding, with saturation. Every tiered billing path (pre-consume, +// settlement, breakdown validation, log fields) MUST use this to avoid +-1 +// discrepancies. +func QuotaRound(value float64) int { + quota, _ := QuotaRoundChecked(value) + return quota +} + +// QuotaRoundChecked is QuotaRound but also returns a non-nil *QuotaClamp when +// the value was clamped, so billing callers can audit it. +func QuotaRoundChecked(value float64) (int, *QuotaClamp) { + return saturateQuota(math.Round(value), "QuotaRound") +} + +// QuotaFromDecimal converts a computed quota decimal to int with saturation. +// The decimal is rounded (half away from zero) before conversion. +func QuotaFromDecimal(d decimal.Decimal) int { + quota, _ := QuotaFromDecimalChecked(d) + return quota +} + +// QuotaFromDecimalChecked is QuotaFromDecimal but also returns a non-nil +// *QuotaClamp when the value was clamped, so billing callers can audit it. +func QuotaFromDecimalChecked(d decimal.Decimal) (int, *QuotaClamp) { + f, _ := d.Round(0).Float64() + return saturateQuota(f, "QuotaFromDecimal") } diff --git a/common/quota_math_test.go b/common/quota_math_test.go index 847d3db0..63a1de29 100644 --- a/common/quota_math_test.go +++ b/common/quota_math_test.go @@ -4,19 +4,105 @@ import ( "math" "testing" + "github.com/shopspring/decimal" "github.com/stretchr/testify/assert" ) +// 2000 quota per call * n=18446744073686646784 overflows int64; the constant +// below reproduces that oversized product for the saturation checks. +const overflowingProduct = 2000 * 1.8446744073686647e19 + // TestQuotaFromFloat guards the billing invariant that oversized quota // products (e.g. price multiplied by a huge user-supplied count) saturate -// instead of wrapping into a negative charge (credit). +// instead of wrapping into a negative charge (credit). QuotaFromFloat +// truncates toward zero. func TestQuotaFromFloat(t *testing.T) { assert.Equal(t, 42, QuotaFromFloat(42.4)) - assert.Equal(t, -42, QuotaFromFloat(-42.4)) - // 2000 quota per call * n=18446744073686646784 overflows int64. - assert.Equal(t, math.MaxInt32, QuotaFromFloat(2000*1.8446744073686647e19)) - assert.Equal(t, math.MinInt32, QuotaFromFloat(-2000*1.8446744073686647e19)) - assert.Equal(t, math.MaxInt32, QuotaFromFloat(math.Inf(1))) - assert.Equal(t, math.MinInt32, QuotaFromFloat(math.Inf(-1))) + assert.Equal(t, 42, QuotaFromFloat(42.9)) + assert.Equal(t, -42, QuotaFromFloat(-42.9)) + assert.Equal(t, MaxQuota, QuotaFromFloat(overflowingProduct)) + assert.Equal(t, MinQuota, QuotaFromFloat(-overflowingProduct)) + assert.Equal(t, MaxQuota, QuotaFromFloat(math.Inf(1))) + assert.Equal(t, MinQuota, QuotaFromFloat(math.Inf(-1))) assert.Equal(t, 0, QuotaFromFloat(math.NaN())) } + +// TestQuotaRound checks half-away-from-zero rounding with the same +// saturation policy. +func TestQuotaRound(t *testing.T) { + assert.Equal(t, 42, QuotaRound(41.5)) + assert.Equal(t, 43, QuotaRound(42.5)) + assert.Equal(t, -43, QuotaRound(-42.5)) + assert.Equal(t, MaxQuota, QuotaRound(overflowingProduct)) + assert.Equal(t, MinQuota, QuotaRound(-overflowingProduct)) + assert.Equal(t, 0, QuotaRound(math.NaN())) +} + +// TestQuotaFromDecimal checks the decimal entry point rounds and saturates +// consistently with the float variants. +func TestQuotaFromDecimal(t *testing.T) { + assert.Equal(t, 43, QuotaFromDecimal(decimal.NewFromFloat(42.5))) + assert.Equal(t, 42, QuotaFromDecimal(decimal.NewFromFloat(41.7))) + assert.Equal(t, MaxQuota, QuotaFromDecimal(decimal.NewFromInt(2000).Mul(decimal.NewFromFloat(1.8446744073686647e19)))) + assert.Equal(t, MinQuota, QuotaFromDecimal(decimal.NewFromInt(-2000).Mul(decimal.NewFromFloat(1.8446744073686647e19)))) +} + +// TestQuotaFromFloatChecked verifies the clamp descriptor is nil in range and +// carries the correct kind/clamped value on saturation, so billing callers can +// audit the event. +func TestQuotaFromFloatChecked(t *testing.T) { + quota, clamp := QuotaFromFloatChecked(42.9) + assert.Equal(t, 42, quota) + assert.Nil(t, clamp) + + quota, clamp = QuotaFromFloatChecked(overflowingProduct) + assert.Equal(t, MaxQuota, quota) + if assert.NotNil(t, clamp) { + assert.Equal(t, "QuotaFromFloat", clamp.Op) + assert.Equal(t, QuotaClampOverflow, clamp.Kind) + assert.Equal(t, MaxQuota, clamp.Clamped) + } + + quota, clamp = QuotaFromFloatChecked(-overflowingProduct) + assert.Equal(t, MinQuota, quota) + if assert.NotNil(t, clamp) { + assert.Equal(t, QuotaClampUnderflow, clamp.Kind) + assert.Equal(t, MinQuota, clamp.Clamped) + } + + quota, clamp = QuotaFromFloatChecked(math.NaN()) + assert.Equal(t, 0, quota) + if assert.NotNil(t, clamp) { + assert.Equal(t, QuotaClampNaN, clamp.Kind) + assert.Equal(t, 0, clamp.Clamped) + } +} + +// TestQuotaRoundChecked verifies the rounding entry point reports clamps the +// same way. +func TestQuotaRoundChecked(t *testing.T) { + quota, clamp := QuotaRoundChecked(42.5) + assert.Equal(t, 43, quota) + assert.Nil(t, clamp) + + quota, clamp = QuotaRoundChecked(overflowingProduct) + assert.Equal(t, MaxQuota, quota) + if assert.NotNil(t, clamp) { + assert.Equal(t, "QuotaRound", clamp.Op) + assert.Equal(t, QuotaClampOverflow, clamp.Kind) + } +} + +// TestQuotaFromDecimalChecked verifies the decimal entry point reports clamps. +func TestQuotaFromDecimalChecked(t *testing.T) { + quota, clamp := QuotaFromDecimalChecked(decimal.NewFromFloat(41.7)) + assert.Equal(t, 42, quota) + assert.Nil(t, clamp) + + quota, clamp = QuotaFromDecimalChecked(decimal.NewFromInt(2000).Mul(decimal.NewFromFloat(1.8446744073686647e19))) + assert.Equal(t, MaxQuota, quota) + if assert.NotNil(t, clamp) { + assert.Equal(t, "QuotaFromDecimal", clamp.Op) + assert.Equal(t, QuotaClampOverflow, clamp.Kind) + } +} diff --git a/controller/task_video.go b/controller/task_video.go index 18c20c06..0c9f5e8d 100644 --- a/controller/task_video.go +++ b/controller/task_video.go @@ -179,7 +179,11 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha } // 计算实际应扣费额度: totalTokens * modelRatio * groupRatio(饱和转换,防止溢出成负数) - actualQuota := common.QuotaFromFloat(float64(taskResult.TotalTokens) * modelRatio * finalGroupRatio) + actualQuota, clamp := common.QuotaFromFloatChecked(float64(taskResult.TotalTokens) * modelRatio * finalGroupRatio) + if clamp != nil { + logger.LogWarn(ctx, fmt.Sprintf("quota saturation on video task %s: op=%s kind=%s original=%g clamped=%d user=%d", + task.TaskID, clamp.Op, clamp.Kind, clamp.Original, clamp.Clamped, task.UserId)) + } // 计算差额 preConsumedQuota := task.Quota @@ -205,7 +209,12 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha logContent := fmt.Sprintf("视频任务成功补扣费,模型倍率 %.2f,分组倍率 %.2f,tokens %d,预扣费 %s,实际扣费 %s,补扣费 %s", modelRatio, finalGroupRatio, taskResult.TotalTokens, logger.LogQuota(preConsumedQuota), logger.LogQuota(actualQuota), logger.LogQuota(quotaDelta)) - model.RecordLog(task.UserId, model.LogTypeSystem, logContent) + if clamp != nil { + model.RecordLogWithAdminInfo(task.UserId, model.LogTypeSystem, logContent, + map[string]interface{}{"quota_saturation": clamp.AuditMap()}) + } else { + model.RecordLog(task.UserId, model.LogTypeSystem, logContent) + } } } else if quotaDelta < 0 { // 需要退还多扣的费用 @@ -226,7 +235,12 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha logContent := fmt.Sprintf("视频任务成功退还多扣费用,模型倍率 %.2f,分组倍率 %.2f,tokens %d,预扣费 %s,实际扣费 %s,退还 %s", modelRatio, finalGroupRatio, taskResult.TotalTokens, logger.LogQuota(preConsumedQuota), logger.LogQuota(actualQuota), logger.LogQuota(refundQuota)) - model.RecordLog(task.UserId, model.LogTypeSystem, logContent) + if clamp != nil { + model.RecordLogWithAdminInfo(task.UserId, model.LogTypeSystem, logContent, + map[string]interface{}{"quota_saturation": clamp.AuditMap()}) + } else { + model.RecordLog(task.UserId, model.LogTypeSystem, logContent) + } } } else { // quotaDelta == 0, 预扣费刚好准确 diff --git a/model/log_format_test.go b/model/log_format_test.go new file mode 100644 index 00000000..f580dda6 --- /dev/null +++ b/model/log_format_test.go @@ -0,0 +1,35 @@ +package model + +import ( + "testing" + + "github.com/QuantumNous/new-api/common" + + "github.com/stretchr/testify/require" +) + +// TestFormatUserLogsStripsQuotaSaturation verifies the admin-only quota +// saturation marker (nested under other.admin_info) is removed for non-admin +// log views, since formatUserLogs strips the whole admin_info object. +func TestFormatUserLogsStripsQuotaSaturation(t *testing.T) { + other := common.MapToJsonStr(map[string]interface{}{ + "model_price": 0.004, + "admin_info": map[string]interface{}{ + "quota_saturation": map[string]interface{}{ + "op": "QuotaFromDecimal", + "kind": "overflow", + "clamped": common.MaxQuota, + }, + }, + }) + logs := []*Log{{Other: other}} + + formatUserLogs(logs, 0) + + parsed, err := common.StrToMap(logs[0].Other) + require.NoError(t, err) + _, hasAdminInfo := parsed["admin_info"] + require.False(t, hasAdminInfo, "admin_info (and nested quota_saturation) must be stripped for non-admin views") + // Non-admin billing fields remain visible. + require.Contains(t, parsed, "model_price") +} diff --git a/pkg/billingexpr/billingexpr_test.go b/pkg/billingexpr/billingexpr_test.go index ee9135bc..7b59ed25 100644 --- a/pkg/billingexpr/billingexpr_test.go +++ b/pkg/billingexpr/billingexpr_test.go @@ -291,12 +291,9 @@ func TestQuotaRound(t *testing.T) { {999.4999, 999}, {999.5, 1000}, {1e9 + 0.5, 1e9 + 1}, - // Oversized expression results must saturate at int32 bounds - // instead of wrapping into a negative charge. + // Oversized expression results saturate at int32 (delegated to + // common.QuotaRound); full saturation coverage lives in common. {3.6893488147419103e19, math.MaxInt32}, - {-3.6893488147419103e19, math.MinInt32}, - {math.Inf(1), math.MaxInt32}, - {math.NaN(), 0}, } for _, tt := range tests { got := billingexpr.QuotaRound(tt.in) diff --git a/pkg/billingexpr/round.go b/pkg/billingexpr/round.go index be6c8f1a..7e7e4196 100644 --- a/pkg/billingexpr/round.go +++ b/pkg/billingexpr/round.go @@ -1,24 +1,14 @@ package billingexpr -import "math" +import "github.com/QuantumNous/new-api/common" // 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. +// rounding with int32 saturation. Every tiered billing path (pre-consume, +// settlement, breakdown validation, log fields) MUST use this function to +// avoid +-1 discrepancies. // -// The result saturates at int32 bounds: quota columns are 32-bit integers in -// the database, and an oversized expression result must never wrap around -// and turn a charge into a credit. +// It delegates to common.QuotaRound so all quota rounding/conversion shares +// one saturation + logging policy (see common/quota_math.go). func QuotaRound(f float64) int { - r := math.Round(f) - if math.IsNaN(r) { - return 0 - } - if r >= math.MaxInt32 { - return math.MaxInt32 - } - if r <= math.MinInt32 { - return math.MinInt32 - } - return int(r) + return common.QuotaRound(f) } diff --git a/pkg/billingexpr/settle.go b/pkg/billingexpr/settle.go index 7a6ca440..f8cf937a 100644 --- a/pkg/billingexpr/settle.go +++ b/pkg/billingexpr/settle.go @@ -1,5 +1,7 @@ package billingexpr +import "github.com/QuantumNous/new-api/common" + // quotaConversion converts raw expression output to quota based on the // expression version. This is the central dispatch point for future versions // that may use a different conversion formula. @@ -23,7 +25,7 @@ func ComputeTieredQuotaWithRequest(snap *BillingSnapshot, params TokenParams, re } quotaBeforeGroup := quotaConversion(cost, snap) - afterGroup := QuotaRound(quotaBeforeGroup * snap.GroupRatio) + afterGroup, clamp := common.QuotaRoundChecked(quotaBeforeGroup * snap.GroupRatio) crossed := trace.MatchedTier != snap.EstimatedTier return TieredResult{ @@ -31,5 +33,6 @@ func ComputeTieredQuotaWithRequest(snap *BillingSnapshot, params TokenParams, re ActualQuotaAfterGroup: afterGroup, MatchedTier: trace.MatchedTier, CrossedTier: crossed, + Clamp: clamp, }, nil } diff --git a/pkg/billingexpr/settle_clamp_test.go b/pkg/billingexpr/settle_clamp_test.go new file mode 100644 index 00000000..4d765b23 --- /dev/null +++ b/pkg/billingexpr/settle_clamp_test.go @@ -0,0 +1,53 @@ +package billingexpr_test + +import ( + "math" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/pkg/billingexpr" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestComputeTieredQuota_ClampOnOverflow guards the billing-safety invariant +// that an oversized tiered settlement clamps to the int32 max instead of +// wrapping into a credit, and that the saturation event is surfaced on the +// result so callers can record it for admin auditing. +func TestComputeTieredQuota_ClampOnOverflow(t *testing.T) { + // exprOutput = p * 1e9 = 1e18; quotaBeforeGroup = 1e18 / 1e6 * 5e5 = 5e17, + // which far exceeds MaxInt32 and must saturate. + exprStr := `tier("base", p * 1000000000)` + snap := &billingexpr.BillingSnapshot{ + BillingMode: "tiered_expr", + ExprString: exprStr, + ExprHash: billingexpr.ExprHashString(exprStr), + GroupRatio: 1.0, + QuotaPerUnit: 500_000, + } + + result, err := billingexpr.ComputeTieredQuota(snap, billingexpr.TokenParams{P: 1_000_000_000}) + require.NoError(t, err) + + assert.Equal(t, math.MaxInt32, result.ActualQuotaAfterGroup, "oversized quota must clamp to int32 max, never wrap negative") + require.NotNil(t, result.Clamp, "clamp event must be surfaced so it can be audited") + assert.Equal(t, common.QuotaClampOverflow, result.Clamp.Kind) + assert.Equal(t, math.MaxInt32, result.Clamp.Clamped) +} + +// TestComputeTieredQuota_NoClampInRange confirms an in-range settlement leaves +// Clamp nil, so the audit path is a no-op in the common case. +func TestComputeTieredQuota_NoClampInRange(t *testing.T) { + exprStr := `tier("base", p * 2 + c * 10)` + snap := &billingexpr.BillingSnapshot{ + BillingMode: "tiered_expr", + ExprString: exprStr, + ExprHash: billingexpr.ExprHashString(exprStr), + GroupRatio: 1.0, + QuotaPerUnit: 500_000, + } + + result, err := billingexpr.ComputeTieredQuota(snap, billingexpr.TokenParams{P: 1000, C: 500}) + require.NoError(t, err) + assert.Nil(t, result.Clamp, "in-range settlement must not report a clamp") +} diff --git a/pkg/billingexpr/types.go b/pkg/billingexpr/types.go index 12e0d3c6..fa30b5c0 100644 --- a/pkg/billingexpr/types.go +++ b/pkg/billingexpr/types.go @@ -3,6 +3,8 @@ package billingexpr import ( "crypto/sha256" "fmt" + + "github.com/QuantumNous/new-api/common" ) type RequestInput struct { @@ -57,6 +59,11 @@ type TieredResult struct { ActualQuotaAfterGroup int `json:"actual_quota_after_group"` MatchedTier string `json:"matched_tier"` CrossedTier bool `json:"crossed_tier"` + // Clamp records an int32 saturation event during quota conversion so the + // caller can surface it on the consume log for admin auditing. Nil when no + // clamping occurred. Not serialized: the marker is attached separately via + // the shared quota-saturation audit path. + Clamp *common.QuotaClamp `json:"-"` } // ExprHashString returns the SHA-256 hex digest of an expression string. diff --git a/relay/channel/openai/audio.go b/relay/channel/openai/audio.go index ec1cbe72..f18819e8 100644 --- a/relay/channel/openai/audio.go +++ b/relay/channel/openai/audio.go @@ -105,7 +105,7 @@ func OpenaiTTSHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rel } else if duration > 0 { // 计算 token: ceil(duration) / 60.0 * 1000,即每分钟 1000 tokens。 // duration 解析自上游返回的音频元数据,饱和转换防止 int 回绕。 - completionTokens := common.QuotaFromFloat(math.Round(math.Ceil(duration) / 60.0 * 1000)) + completionTokens := common.QuotaRound(math.Ceil(duration) / 60.0 * 1000) usage.CompletionTokens = completionTokens usage.CompletionTokenDetails.AudioTokens = completionTokens } diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index f97f335c..9f460ce5 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -163,6 +163,11 @@ type RelayInfo struct { PriceData types.PriceData + // QuotaClamp is set (non-nil) when a quota conversion saturated at the + // int32 bound (or NaN fallback) while computing this request's charge. + // It is surfaced onto the consume/task log's admin_info for auditing. + QuotaClamp *common.QuotaClamp + // 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 diff --git a/relay/relay_task.go b/relay/relay_task.go index 037e4076..c103f7d5 100644 --- a/relay/relay_task.go +++ b/relay/relay_task.go @@ -202,7 +202,9 @@ func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (*TaskSubmitRe quotaWithRatios *= ra } } - info.PriceData.Quota = common.QuotaFromFloat(quotaWithRatios) + quota, clamp := common.QuotaFromFloatChecked(quotaWithRatios) + info.PriceData.Quota = quota + noteTaskQuotaClamp(info, clamp) } // 7. 预扣费(仅首次 — 重试时 info.Billing 已存在,跳过) @@ -278,7 +280,21 @@ func recalcQuotaFromRatios(info *relaycommon.RelayInfo, ratios map[string]float6 result *= ra } } - return common.QuotaFromFloat(result) + quota, clamp := common.QuotaFromFloatChecked(result) + noteTaskQuotaClamp(info, clamp) + return quota +} + +// noteTaskQuotaClamp records the first quota saturation event onto the task's +// RelayInfo so LogTaskConsumption can surface it on the submit log's +// admin_info. First non-nil clamp wins. +func noteTaskQuotaClamp(info *relaycommon.RelayInfo, clamp *common.QuotaClamp) { + if clamp == nil || info == nil { + return + } + if info.QuotaClamp == nil { + info.QuotaClamp = clamp + } } var fetchRespBuilders = map[int]func(c *gin.Context) (respBody []byte, taskResp *dto.TaskError){ diff --git a/service/log_info_generate.go b/service/log_info_generate.go index 54448d59..207b0af5 100644 --- a/service/log_info_generate.go +++ b/service/log_info_generate.go @@ -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 diff --git a/service/quota.go b/service/quota.go index d3aab3dc..e5d4ec7e 100644 --- a/service/quota.go +++ b/service/quota.go @@ -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, diff --git a/service/quota_saturation_test.go b/service/quota_saturation_test.go new file mode 100644 index 00000000..ae8cdb6d --- /dev/null +++ b/service/quota_saturation_test.go @@ -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") +} diff --git a/service/task_billing.go b/service/task_billing.go index 2c75b06c..9e43ee2b 100644 --- a/service/task_billing.go +++ b/service/task_billing.go @@ -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) } diff --git a/service/text_quota.go b/service/text_quota.go index e30e0b3d..0d452fba 100644 --- a/service/text_quota.go +++ b/service/text_quota.go @@ -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, diff --git a/service/text_quota_test.go b/service/text_quota_test.go index 2988a2ce..c18fc47e 100644 --- a/service/text_quota_test.go +++ b/service/text_quota_test.go @@ -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") +} diff --git a/service/tiered_settle.go b/service/tiered_settle.go index a97ec088..eede3450 100644 --- a/service/tiered_settle.go +++ b/service/tiered_settle.go @@ -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 } diff --git a/service/token_counter.go b/service/token_counter.go index e045c407..6fcc4f1c 100644 --- a/service/token_counter.go +++ b/service/token_counter.go @@ -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 } diff --git a/service/tool_billing.go b/service/tool_billing.go index 15993067..cadc750c 100644 --- a/service/tool_billing.go +++ b/service/tool_billing.go @@ -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, diff --git a/web/default/src/features/usage-logs/components/columns/common-logs-columns.tsx b/web/default/src/features/usage-logs/components/columns/common-logs-columns.tsx index 15c547f9..404f02eb 100644 --- a/web/default/src/features/usage-logs/components/columns/common-logs-columns.tsx +++ b/web/default/src/features/usage-logs/components/columns/common-logs-columns.tsx @@ -104,6 +104,23 @@ function splitQuotaDisplay(value: string): { prefix: string; amount: string } { } function buildDetailSegments( + log: UsageLog, + other: LogOtherData | null, + t: (key: string, opts?: Record) => string, + isAdmin: boolean +): DetailSegment[] { + const segments = buildTypeDetailSegments(log, other, t) + // Quota saturation is a rare, admin-only anomaly marker; surface it first + // and in danger styling so it stands out on the related billing log. The + // backend already strips admin_info for non-admins; gate on isAdmin too as + // defense in depth so the marker never leaks if that changes. + if (isAdmin && other?.admin_info?.quota_saturation) { + return [{ text: t('Quota clamped'), danger: true }, ...segments] + } + return segments +} + +function buildTypeDetailSegments( log: UsageLog, other: LogOtherData | null, t: (key: string, opts?: Record) => string @@ -817,7 +834,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef[] { const log = row.original const other = parseLogOther(log.other) - const segments = buildDetailSegments(log, other, t) + const segments = buildDetailSegments(log, other, t, isAdmin) const primary = segments[0] const hasMore = segments.length > 1 diff --git a/web/default/src/features/usage-logs/components/dialogs/details-dialog.tsx b/web/default/src/features/usage-logs/components/dialogs/details-dialog.tsx index 44d7d918..9b19a6d5 100644 --- a/web/default/src/features/usage-logs/components/dialogs/details-dialog.tsx +++ b/web/default/src/features/usage-logs/components/dialogs/details-dialog.tsx @@ -143,6 +143,15 @@ function formatRatio(ratio: number | undefined): string { return ratio.toFixed(4) } +function quotaSaturationKindLabel( + kind: 'overflow' | 'underflow' | 'nan', + t: (key: string) => string +): string { + if (kind === 'overflow') return t('Overflow') + if (kind === 'underflow') return t('Underflow') + return t('Invalid (NaN)') +} + function BillingBreakdown(props: { log: UsageLog other: LogOtherData @@ -704,6 +713,41 @@ export function DetailsDialog(props: DetailsDialogProps) { )} + {/* Quota saturation marker (admin only) */} + {props.isAdmin && other?.admin_info?.quota_saturation && ( + + )} + {/* Reject reason (admin only) */} {props.isAdmin && other?.reject_reason && ( = { [LOG_TYPE_ENUM.REFUND]: 'bg-blue-50/30 dark:bg-blue-950/15', } +// Warning tint for logs where a quota conversion saturated (admin-only marker). +// Takes precedence over the per-type tint since it flags a billing anomaly. +const quotaSaturationRowTint = 'bg-amber-50/60 dark:bg-amber-950/25' + function getColumnVisibilityStorageKey( logCategory: LogCategory, isAdmin: boolean @@ -204,8 +209,16 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) { const logType = (row.original as Record).type as | number | undefined - const tintClass = + let tintClass = isCommon && logType != null ? (logTypeRowTint[logType] ?? '') : '' + if (isCommon && isAdmin) { + const other = parseLogOther( + ((row.original as Record).other as string) ?? '' + ) + if (other?.admin_info?.quota_saturation) { + tintClass = quotaSaturationRowTint + } + } return (