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:
@@ -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
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user