fix(billing): improve quota handling and error reporting for pre-consume operations
This commit is contained in:
+44
-13
@@ -16,11 +16,14 @@ const (
|
|||||||
MinQuota = math.MinInt32
|
MinQuota = math.MinInt32
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// QuotaClampKind identifies why a quota conversion had to be saturated.
|
||||||
|
type QuotaClampKind string
|
||||||
|
|
||||||
// Clamp kinds reported by QuotaClamp.Kind.
|
// Clamp kinds reported by QuotaClamp.Kind.
|
||||||
const (
|
const (
|
||||||
QuotaClampOverflow = "overflow"
|
QuotaClampOverflow QuotaClampKind = "overflow"
|
||||||
QuotaClampUnderflow = "underflow"
|
QuotaClampUnderflow QuotaClampKind = "underflow"
|
||||||
QuotaClampNaN = "nan"
|
QuotaClampNaN QuotaClampKind = "nan"
|
||||||
)
|
)
|
||||||
|
|
||||||
// QuotaClamp describes a single saturation event: a quota conversion whose
|
// QuotaClamp describes a single saturation event: a quota conversion whose
|
||||||
@@ -28,10 +31,19 @@ const (
|
|||||||
// therefore clamped. It is surfaced to billing callers so the event can be
|
// therefore clamped. It is surfaced to billing callers so the event can be
|
||||||
// recorded on the related consume/task log for admin auditing.
|
// recorded on the related consume/task log for admin auditing.
|
||||||
type QuotaClamp struct {
|
type QuotaClamp struct {
|
||||||
Op string `json:"op"` // "QuotaFromFloat" | "QuotaRound" | "QuotaFromDecimal"
|
Op string `json:"op"` // "QuotaFromFloat" | "QuotaRound" | "QuotaFromDecimal"
|
||||||
Kind string `json:"kind"` // "overflow" | "underflow" | "nan"
|
Kind QuotaClampKind `json:"kind"` // "overflow" | "underflow" | "nan"
|
||||||
Original float64 `json:"original"` // best-effort pre-clamp value (decimal -> float64 approx)
|
Original float64 `json:"original"` // best-effort pre-clamp value (decimal -> float64 approx)
|
||||||
Clamped int `json:"clamped"` // the saturated result actually used
|
Clamped int `json:"clamped"` // the saturated result actually used
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error lets the same typed value serve both as the settlement audit marker
|
||||||
|
// and as the fail-fast error returned by strict pre-consume conversions.
|
||||||
|
func (c *QuotaClamp) Error() string {
|
||||||
|
if c == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("quota conversion (%s) %s: original=%g, clamped=%d", c.Op, c.Kind, c.Original, c.Clamped)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AuditMap renders the clamp as the marker stored under a log's
|
// AuditMap renders the clamp as the marker stored under a log's
|
||||||
@@ -58,19 +70,26 @@ func (c *QuotaClamp) AuditMap() map[string]interface{} {
|
|||||||
// record the event (e.g. on the consume log); the returned pointer is nil for
|
// record the event (e.g. on the consume log); the returned pointer is nil for
|
||||||
// in-range values.
|
// in-range values.
|
||||||
func saturateQuota(value float64, op string) (int, *QuotaClamp) {
|
func saturateQuota(value float64, op string) (int, *QuotaClamp) {
|
||||||
|
var clamp *QuotaClamp
|
||||||
switch {
|
switch {
|
||||||
case math.IsNaN(value):
|
case math.IsNaN(value):
|
||||||
SysError(fmt.Sprintf("quota conversion (%s) received NaN, falling back to 0", op))
|
clamp = &QuotaClamp{Op: op, Kind: QuotaClampNaN, Original: value, Clamped: 0}
|
||||||
return 0, &QuotaClamp{Op: op, Kind: QuotaClampNaN, Original: value, Clamped: 0}
|
|
||||||
case value >= MaxQuota:
|
case value >= MaxQuota:
|
||||||
SysError(fmt.Sprintf("quota conversion (%s) overflow: %g exceeds max quota, clamped to %d", op, value, MaxQuota))
|
clamp = &QuotaClamp{Op: op, Kind: QuotaClampOverflow, Original: value, Clamped: MaxQuota}
|
||||||
return MaxQuota, &QuotaClamp{Op: op, Kind: QuotaClampOverflow, Original: value, Clamped: MaxQuota}
|
|
||||||
case value <= MinQuota:
|
case value <= MinQuota:
|
||||||
SysError(fmt.Sprintf("quota conversion (%s) underflow: %g below min quota, clamped to %d", op, value, MinQuota))
|
clamp = &QuotaClamp{Op: op, Kind: QuotaClampUnderflow, Original: value, Clamped: MinQuota}
|
||||||
return MinQuota, &QuotaClamp{Op: op, Kind: QuotaClampUnderflow, Original: value, Clamped: MinQuota}
|
|
||||||
default:
|
default:
|
||||||
return int(value), nil
|
return int(value), nil
|
||||||
}
|
}
|
||||||
|
SysError(clamp.Error())
|
||||||
|
return clamp.Clamped, clamp
|
||||||
|
}
|
||||||
|
|
||||||
|
func strictQuota(quota int, clamp *QuotaClamp) (int, error) {
|
||||||
|
if clamp != nil {
|
||||||
|
return 0, clamp
|
||||||
|
}
|
||||||
|
return quota, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// QuotaFromFloat converts a computed quota value to int, truncating toward
|
// QuotaFromFloat converts a computed quota value to int, truncating toward
|
||||||
@@ -87,6 +106,12 @@ func QuotaFromFloatChecked(value float64) (int, *QuotaClamp) {
|
|||||||
return saturateQuota(value, "QuotaFromFloat")
|
return saturateQuota(value, "QuotaFromFloat")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QuotaFromFloatStrict converts an in-range value and returns a typed
|
||||||
|
// *QuotaClamp error instead of allowing a saturated result to reach billing.
|
||||||
|
func QuotaFromFloatStrict(value float64) (int, error) {
|
||||||
|
return strictQuota(QuotaFromFloatChecked(value))
|
||||||
|
}
|
||||||
|
|
||||||
// QuotaRound converts a float64 quota value to int using half-away-from-zero
|
// QuotaRound converts a float64 quota value to int using half-away-from-zero
|
||||||
// rounding, with saturation. Every tiered billing path (pre-consume,
|
// rounding, with saturation. Every tiered billing path (pre-consume,
|
||||||
// settlement, breakdown validation, log fields) MUST use this to avoid +-1
|
// settlement, breakdown validation, log fields) MUST use this to avoid +-1
|
||||||
@@ -102,6 +127,12 @@ func QuotaRoundChecked(value float64) (int, *QuotaClamp) {
|
|||||||
return saturateQuota(math.Round(value), "QuotaRound")
|
return saturateQuota(math.Round(value), "QuotaRound")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QuotaRoundStrict rounds an in-range value and returns a typed *QuotaClamp
|
||||||
|
// error instead of allowing a saturated result to reach billing.
|
||||||
|
func QuotaRoundStrict(value float64) (int, error) {
|
||||||
|
return strictQuota(QuotaRoundChecked(value))
|
||||||
|
}
|
||||||
|
|
||||||
// QuotaFromDecimal converts a computed quota decimal to int with saturation.
|
// QuotaFromDecimal converts a computed quota decimal to int with saturation.
|
||||||
// The decimal is rounded (half away from zero) before conversion.
|
// The decimal is rounded (half away from zero) before conversion.
|
||||||
func QuotaFromDecimal(d decimal.Decimal) int {
|
func QuotaFromDecimal(d decimal.Decimal) int {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
|
|
||||||
"github.com/shopspring/decimal"
|
"github.com/shopspring/decimal"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 2000 quota per call * n=18446744073686646784 overflows int64; the constant
|
// 2000 quota per call * n=18446744073686646784 overflows int64; the constant
|
||||||
@@ -78,6 +79,23 @@ func TestQuotaFromFloatChecked(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestQuotaFromFloatStrictReturnsTypedClampError(t *testing.T) {
|
||||||
|
quota, err := QuotaFromFloatStrict(42.9)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 42, quota)
|
||||||
|
|
||||||
|
quota, err = QuotaFromFloatStrict(overflowingProduct)
|
||||||
|
assert.Zero(t, quota)
|
||||||
|
var clamp *QuotaClamp
|
||||||
|
require.ErrorAs(t, err, &clamp)
|
||||||
|
assert.Equal(t, QuotaClampOverflow, clamp.Kind)
|
||||||
|
assert.Equal(t, MaxQuota, clamp.Clamped)
|
||||||
|
assert.ErrorContains(t, err, "QuotaFromFloat")
|
||||||
|
assert.ErrorContains(t, err, "overflow")
|
||||||
|
assert.ErrorContains(t, err, "original=")
|
||||||
|
assert.ErrorContains(t, err, "clamped=2147483647")
|
||||||
|
}
|
||||||
|
|
||||||
// TestQuotaRoundChecked verifies the rounding entry point reports clamps the
|
// TestQuotaRoundChecked verifies the rounding entry point reports clamps the
|
||||||
// same way.
|
// same way.
|
||||||
func TestQuotaRoundChecked(t *testing.T) {
|
func TestQuotaRoundChecked(t *testing.T) {
|
||||||
|
|||||||
+9
-4
@@ -155,14 +155,19 @@ func (i *ImageRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// n is NOT included here; it is handled via OtherRatio("n") in
|
imageN := uint(1)
|
||||||
// image_handler.go (default) or channel adaptors (actual count).
|
if i.N != nil && *i.N > 0 {
|
||||||
// Including n here caused double-counting for channels that also
|
imageN = *i.N
|
||||||
// set OtherRatio("n") (e.g. Ali/Bailian).
|
}
|
||||||
|
|
||||||
|
// Keep n separate from ImagePriceRatio so size/quality and count remain
|
||||||
|
// independent billing dimensions. Fixed-price pre-consume stores this on
|
||||||
|
// PriceData, and image settlement reuses or replaces the same "n" ratio.
|
||||||
return &types.TokenCountMeta{
|
return &types.TokenCountMeta{
|
||||||
CombineText: i.Prompt,
|
CombineText: i.Prompt,
|
||||||
MaxTokens: 1584,
|
MaxTokens: 1584,
|
||||||
ImagePriceRatio: sizeRatio * qualityRatio,
|
ImagePriceRatio: sizeRatio * qualityRatio,
|
||||||
|
BillingRatios: map[string]float64{"n": float64(imageN)},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,9 +13,7 @@ func QuotaRound(f float64) int {
|
|||||||
return common.QuotaRound(f)
|
return common.QuotaRound(f)
|
||||||
}
|
}
|
||||||
|
|
||||||
// QuotaRoundChecked is QuotaRound but also reports whether the result had to
|
// QuotaRoundStrict rejects an unrepresentable pre-consume estimate.
|
||||||
// be saturated. Pre-consume callers use this to reject an unrepresentable
|
func QuotaRoundStrict(f float64) (int, error) {
|
||||||
// estimate before any quota is deducted.
|
return common.QuotaRoundStrict(f)
|
||||||
func QuotaRoundChecked(f float64) (int, *common.QuotaClamp) {
|
|
||||||
return common.QuotaRoundChecked(f)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,6 +108,16 @@ func TestGetAndValidOpenAIImageRequestNBounds(t *testing.T) {
|
|||||||
body: fmt.Sprintf(`{"model":"gpt-image-1","prompt":"a cat","n":%d}`, dto.MaxImageN),
|
body: fmt.Sprintf(`{"model":"gpt-image-1","prompt":"a cat","n":%d}`, dto.MaxImageN),
|
||||||
wantN: dto.MaxImageN,
|
wantN: dto.MaxImageN,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "explicit n is accepted",
|
||||||
|
body: `{"model":"gpt-image-1","prompt":"a cat","n":3}`,
|
||||||
|
wantN: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "zero n defaults to 1",
|
||||||
|
body: `{"model":"gpt-image-1","prompt":"a cat","n":0}`,
|
||||||
|
wantN: 1,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "absent n defaults to 1",
|
name: "absent n defaults to 1",
|
||||||
body: `{"model":"gpt-image-1","prompt":"a cat"}`,
|
body: `{"model":"gpt-image-1","prompt":"a cat"}`,
|
||||||
@@ -127,6 +137,7 @@ func TestGetAndValidOpenAIImageRequestNBounds(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, req.N)
|
require.NotNil(t, req.N)
|
||||||
require.Equal(t, tt.wantN, *req.N)
|
require.Equal(t, tt.wantN, *req.N)
|
||||||
|
require.Equal(t, float64(tt.wantN), req.GetTokenCountMeta().BillingRatios["n"])
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+26
-24
@@ -32,10 +32,6 @@ func modelPriceNotConfiguredError(modelName string, userId int) error {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func preConsumeQuotaRangeError(modelName string, clamp *common.QuotaClamp) error {
|
|
||||||
return fmt.Errorf("model %s pre-consume quota is out of range: operation=%s kind=%s value=%g", modelName, clamp.Op, clamp.Kind, clamp.Original)
|
|
||||||
}
|
|
||||||
|
|
||||||
// https://docs.claude.com/en/docs/build-with-claude/prompt-caching#1-hour-cache-duration
|
// https://docs.claude.com/en/docs/build-with-claude/prompt-caching#1-hour-cache-duration
|
||||||
const claudeCacheCreation1hMultiplier = 6 / 3.75
|
const claudeCacheCreation1hMultiplier = 6 / 3.75
|
||||||
|
|
||||||
@@ -121,20 +117,15 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
|
|||||||
audioRatio = ratio_setting.GetAudioRatio(info.OriginModelName)
|
audioRatio = ratio_setting.GetAudioRatio(info.OriginModelName)
|
||||||
audioCompletionRatio = ratio_setting.GetAudioCompletionRatio(info.OriginModelName)
|
audioCompletionRatio = ratio_setting.GetAudioCompletionRatio(info.OriginModelName)
|
||||||
ratio := modelRatio * groupRatioInfo.GroupRatio
|
ratio := modelRatio * groupRatioInfo.GroupRatio
|
||||||
var clamp *common.QuotaClamp
|
quota, err := common.QuotaFromFloatStrict(float64(preConsumedTokens) * ratio)
|
||||||
preConsumedQuota, clamp = common.QuotaFromFloatChecked(float64(preConsumedTokens) * ratio)
|
if err != nil {
|
||||||
if clamp != nil {
|
return types.PriceData{}, err
|
||||||
return types.PriceData{}, preConsumeQuotaRangeError(info.OriginModelName, clamp)
|
|
||||||
}
|
}
|
||||||
|
preConsumedQuota = quota
|
||||||
} else {
|
} else {
|
||||||
if meta.ImagePriceRatio != 0 {
|
if meta.ImagePriceRatio != 0 {
|
||||||
modelPrice = modelPrice * meta.ImagePriceRatio
|
modelPrice = modelPrice * meta.ImagePriceRatio
|
||||||
}
|
}
|
||||||
var clamp *common.QuotaClamp
|
|
||||||
preConsumedQuota, clamp = common.QuotaFromFloatChecked(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio)
|
|
||||||
if clamp != nil {
|
|
||||||
return types.PriceData{}, preConsumeQuotaRangeError(info.OriginModelName, clamp)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// check if free model pre-consume is disabled
|
// check if free model pre-consume is disabled
|
||||||
@@ -172,6 +163,17 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
|
|||||||
CacheCreation1hRatio: cacheCreationRatio1h,
|
CacheCreation1hRatio: cacheCreationRatio1h,
|
||||||
QuotaToPreConsume: preConsumedQuota,
|
QuotaToPreConsume: preConsumedQuota,
|
||||||
}
|
}
|
||||||
|
if usePrice {
|
||||||
|
for name, ratio := range meta.BillingRatios {
|
||||||
|
priceData.AddOtherRatio(name, ratio)
|
||||||
|
}
|
||||||
|
quotaToPreConsume := priceData.ApplyOtherRatiosToFloat(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio)
|
||||||
|
quota, err := common.QuotaFromFloatStrict(quotaToPreConsume)
|
||||||
|
if err != nil {
|
||||||
|
return types.PriceData{}, err
|
||||||
|
}
|
||||||
|
priceData.QuotaToPreConsume = quota
|
||||||
|
}
|
||||||
|
|
||||||
if common.DebugEnabled {
|
if common.DebugEnabled {
|
||||||
logger.LogDebug(c, "model_price_helper result: %s", priceData.ToSetting())
|
logger.LogDebug(c, "model_price_helper result: %s", priceData.ToSetting())
|
||||||
@@ -211,10 +213,10 @@ func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) (types
|
|||||||
freeModel := false
|
freeModel := false
|
||||||
|
|
||||||
if usePrice {
|
if usePrice {
|
||||||
var clamp *common.QuotaClamp
|
var err error
|
||||||
quota, clamp = common.QuotaFromFloatChecked(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio)
|
quota, err = common.QuotaFromFloatStrict(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio)
|
||||||
if clamp != nil {
|
if err != nil {
|
||||||
return types.PriceData{}, preConsumeQuotaRangeError(info.OriginModelName, clamp)
|
return types.PriceData{}, err
|
||||||
}
|
}
|
||||||
if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume {
|
if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume {
|
||||||
if groupRatioInfo.GroupRatio == 0 || modelPrice == 0 {
|
if groupRatioInfo.GroupRatio == 0 || modelPrice == 0 {
|
||||||
@@ -224,10 +226,10 @@ func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) (types
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 按量计费:以模型倍率的一半作为预扣额度
|
// 按量计费:以模型倍率的一半作为预扣额度
|
||||||
var clamp *common.QuotaClamp
|
var err error
|
||||||
quota, clamp = common.QuotaFromFloatChecked(modelRatio / 2 * common.QuotaPerUnit * groupRatioInfo.GroupRatio)
|
quota, err = common.QuotaFromFloatStrict(modelRatio / 2 * common.QuotaPerUnit * groupRatioInfo.GroupRatio)
|
||||||
if clamp != nil {
|
if err != nil {
|
||||||
return types.PriceData{}, preConsumeQuotaRangeError(info.OriginModelName, clamp)
|
return types.PriceData{}, err
|
||||||
}
|
}
|
||||||
modelPrice = -1
|
modelPrice = -1
|
||||||
if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume {
|
if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume {
|
||||||
@@ -290,9 +292,9 @@ func modelPriceHelperTiered(c *gin.Context, info *relaycommon.RelayInfo, promptT
|
|||||||
|
|
||||||
// Expression coefficients are $/1M tokens prices; convert to quota the same way per-call billing does.
|
// Expression coefficients are $/1M tokens prices; convert to quota the same way per-call billing does.
|
||||||
quotaBeforeGroup := rawCost / 1_000_000 * common.QuotaPerUnit
|
quotaBeforeGroup := rawCost / 1_000_000 * common.QuotaPerUnit
|
||||||
preConsumedQuota, clamp := billingexpr.QuotaRoundChecked(quotaBeforeGroup * groupRatioInfo.GroupRatio)
|
preConsumedQuota, err := billingexpr.QuotaRoundStrict(quotaBeforeGroup * groupRatioInfo.GroupRatio)
|
||||||
if clamp != nil {
|
if err != nil {
|
||||||
return types.PriceData{}, preConsumeQuotaRangeError(info.OriginModelName, clamp)
|
return types.PriceData{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
freeModel := false
|
freeModel := false
|
||||||
|
|||||||
+100
-2
@@ -10,6 +10,7 @@ import (
|
|||||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||||
"github.com/QuantumNous/new-api/setting/billing_setting"
|
"github.com/QuantumNous/new-api/setting/billing_setting"
|
||||||
"github.com/QuantumNous/new-api/setting/config"
|
"github.com/QuantumNous/new-api/setting/config"
|
||||||
|
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||||
"github.com/QuantumNous/new-api/types"
|
"github.com/QuantumNous/new-api/types"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -52,7 +53,9 @@ func TestModelPriceHelperTieredUsesPreloadedRequestInput(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
priceData, err := ModelPriceHelper(ctx, info, 1000, &types.TokenCountMeta{})
|
priceData, err := ModelPriceHelper(ctx, info, 1000, &types.TokenCountMeta{
|
||||||
|
BillingRatios: map[string]float64{"n": 3},
|
||||||
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, 1500, priceData.QuotaToPreConsume)
|
require.Equal(t, 1500, priceData.QuotaToPreConsume)
|
||||||
require.NotNil(t, info.TieredBillingSnapshot)
|
require.NotNil(t, info.TieredBillingSnapshot)
|
||||||
@@ -172,5 +175,100 @@ func TestModelPriceHelperTieredRejectsPreConsumeOverflow(t *testing.T) {
|
|||||||
|
|
||||||
_, err := ModelPriceHelper(ctx, info, 1000, &types.TokenCountMeta{})
|
_, err := ModelPriceHelper(ctx, info, 1000, &types.TokenCountMeta{})
|
||||||
|
|
||||||
require.ErrorContains(t, err, "pre-consume quota is out of range")
|
var clamp *common.QuotaClamp
|
||||||
|
require.ErrorAs(t, err, &clamp)
|
||||||
|
require.Equal(t, "QuotaRound", clamp.Op)
|
||||||
|
require.Equal(t, common.QuotaClampOverflow, clamp.Kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModelPriceHelperRequestBillingRatiosOnlyApplyToFixedPrice(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
savedModelPrices := ratio_setting.ModelPrice2JSONString()
|
||||||
|
savedModelRatios := ratio_setting.ModelRatio2JSONString()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
require.NoError(t, ratio_setting.UpdateModelPriceByJSONString(savedModelPrices))
|
||||||
|
require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(savedModelRatios))
|
||||||
|
})
|
||||||
|
|
||||||
|
modelPrices, err := common.Marshal(map[string]float64{
|
||||||
|
"fixed-image-price": 0.04,
|
||||||
|
"fractional-image-price": 0.0000012,
|
||||||
|
"overflow-image-price": float64(common.MaxQuota) / common.QuotaPerUnit / 2,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, ratio_setting.UpdateModelPriceByJSONString(string(modelPrices)))
|
||||||
|
modelRatios, err := common.Marshal(map[string]float64{"ratio-image-price": 15})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(string(modelRatios)))
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
model string
|
||||||
|
wantQuota int
|
||||||
|
wantUsePrice bool
|
||||||
|
wantImageCount bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "fixed price applies image count",
|
||||||
|
model: "fixed-image-price",
|
||||||
|
wantQuota: 180000,
|
||||||
|
wantUsePrice: true,
|
||||||
|
wantImageCount: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ratio price ignores request billing ratios",
|
||||||
|
model: "ratio-image-price",
|
||||||
|
wantQuota: 15000,
|
||||||
|
wantUsePrice: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||||
|
ctx.Set("group", "default")
|
||||||
|
info := &relaycommon.RelayInfo{
|
||||||
|
OriginModelName: tt.model,
|
||||||
|
UserGroup: "default",
|
||||||
|
UsingGroup: "default",
|
||||||
|
}
|
||||||
|
meta := &types.TokenCountMeta{
|
||||||
|
ImagePriceRatio: 3,
|
||||||
|
BillingRatios: map[string]float64{"n": 3},
|
||||||
|
}
|
||||||
|
|
||||||
|
priceData, err := ModelPriceHelper(ctx, info, 1000, meta)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, tt.wantQuota, priceData.QuotaToPreConsume)
|
||||||
|
require.Equal(t, tt.wantUsePrice, priceData.UsePrice)
|
||||||
|
require.Equal(t, tt.wantImageCount, priceData.HasOtherRatio("n"))
|
||||||
|
require.Equal(t, priceData.OtherRatios(), info.PriceData.OtherRatios())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
newInfo := func(model string) (*gin.Context, *relaycommon.RelayInfo) {
|
||||||
|
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||||
|
ctx.Set("group", "default")
|
||||||
|
return ctx, &relaycommon.RelayInfo{
|
||||||
|
OriginModelName: model,
|
||||||
|
UserGroup: "default",
|
||||||
|
UsingGroup: "default",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
meta := &types.TokenCountMeta{BillingRatios: map[string]float64{"n": 3}}
|
||||||
|
|
||||||
|
ctx, info := newInfo("fractional-image-price")
|
||||||
|
priceData, err := ModelPriceHelper(ctx, info, 0, meta)
|
||||||
|
require.NoError(t, err)
|
||||||
|
// 0.0000012 * 500000 * 3 = 1.8, then truncate once to 1.
|
||||||
|
require.Equal(t, 1, priceData.QuotaToPreConsume)
|
||||||
|
|
||||||
|
ctx, info = newInfo("overflow-image-price")
|
||||||
|
_, err = ModelPriceHelper(ctx, info, 0, meta)
|
||||||
|
var clamp *common.QuotaClamp
|
||||||
|
require.ErrorAs(t, err, &clamp)
|
||||||
|
require.Equal(t, "QuotaFromFloat", clamp.Op)
|
||||||
|
require.Equal(t, common.QuotaClampOverflow, clamp.Kind)
|
||||||
|
require.Nil(t, info.Billing)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,16 +123,6 @@ func ImageHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type
|
|||||||
imageN = *request.N
|
imageN = *request.N
|
||||||
}
|
}
|
||||||
|
|
||||||
// n is handled via OtherRatio so it is applied exactly once in quota
|
|
||||||
// calculation (both price-based and ratio-based paths).
|
|
||||||
// Adaptors may have already set a more accurate count from the
|
|
||||||
// upstream response; only set the default when they haven't.
|
|
||||||
if info.PriceData.UsePrice { // only price model use N ratio
|
|
||||||
if !info.PriceData.HasOtherRatio("n") {
|
|
||||||
info.PriceData.AddOtherRatio("n", float64(imageN))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if usage.(*dto.Usage).TotalTokens == 0 {
|
if usage.(*dto.Usage).TotalTokens == 0 {
|
||||||
usage.(*dto.Usage).TotalTokens = 1
|
usage.(*dto.Usage).TotalTokens = 1
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-2
@@ -19,9 +19,8 @@ const (
|
|||||||
// 会话存储在 relayInfo.Billing 上,供后续 Settle / Refund 使用。
|
// 会话存储在 relayInfo.Billing 上,供后续 Settle / Refund 使用。
|
||||||
func PreConsumeBilling(c *gin.Context, preConsumedQuota int, relayInfo *relaycommon.RelayInfo) *types.NewAPIError {
|
func PreConsumeBilling(c *gin.Context, preConsumedQuota int, relayInfo *relaycommon.RelayInfo) *types.NewAPIError {
|
||||||
if relayInfo != nil && relayInfo.QuotaClamp != nil {
|
if relayInfo != nil && relayInfo.QuotaClamp != nil {
|
||||||
clamp := relayInfo.QuotaClamp
|
|
||||||
return types.NewErrorWithStatusCode(
|
return types.NewErrorWithStatusCode(
|
||||||
fmt.Errorf("pre-consume quota is out of range: operation=%s kind=%s value=%g", clamp.Op, clamp.Kind, clamp.Original),
|
relayInfo.QuotaClamp,
|
||||||
types.ErrorCodeModelPriceError,
|
types.ErrorCodeModelPriceError,
|
||||||
http.StatusBadRequest,
|
http.StatusBadRequest,
|
||||||
types.ErrOptionWithSkipRetry(),
|
types.ErrOptionWithSkipRetry(),
|
||||||
|
|||||||
@@ -92,6 +92,10 @@ func TestPreConsumeBillingRejectsSaturatedQuotaBeforeDeduction(t *testing.T) {
|
|||||||
require.NotNil(t, apiErr)
|
require.NotNil(t, apiErr)
|
||||||
require.Equal(t, types.ErrorCodeModelPriceError, apiErr.GetErrorCode())
|
require.Equal(t, types.ErrorCodeModelPriceError, apiErr.GetErrorCode())
|
||||||
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode)
|
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode)
|
||||||
|
require.Same(t, info.QuotaClamp, apiErr.Err)
|
||||||
|
var clamp *common.QuotaClamp
|
||||||
|
require.ErrorAs(t, apiErr, &clamp)
|
||||||
|
require.Same(t, info.QuotaClamp, clamp)
|
||||||
require.Nil(t, info.Billing)
|
require.Nil(t, info.Billing)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -490,3 +490,31 @@ func TestTryTieredSettleNoClampInRange(t *testing.T) {
|
|||||||
require.NotNil(t, result)
|
require.NotNil(t, result)
|
||||||
require.Nil(t, relayInfo.QuotaClamp, "in-range settlement must not record a clamp")
|
require.Nil(t, relayInfo.QuotaClamp, "in-range settlement must not record a clamp")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCalculateTextQuotaSummaryFixedPriceAppliesImageCountOnceAndAllowsOverride(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||||
|
priceData := types.PriceData{
|
||||||
|
ModelPrice: 0.12,
|
||||||
|
UsePrice: true,
|
||||||
|
GroupRatioInfo: types.GroupRatioInfo{
|
||||||
|
GroupRatio: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
priceData.AddOtherRatio("n", 3)
|
||||||
|
relayInfo := &relaycommon.RelayInfo{
|
||||||
|
OriginModelName: "dall-e-3",
|
||||||
|
PriceData: priceData,
|
||||||
|
StartTime: time.Now(),
|
||||||
|
}
|
||||||
|
usage := &dto.Usage{PromptTokens: 1, TotalTokens: 1}
|
||||||
|
|
||||||
|
summary := calculateTextQuotaSummary(ctx, relayInfo, usage)
|
||||||
|
require.Equal(t, 180000, summary.Quota)
|
||||||
|
|
||||||
|
// An adaptor-reported actual count replaces the requested count rather
|
||||||
|
// than multiplying it a second time.
|
||||||
|
relayInfo.PriceData.AddOtherRatio("n", 2)
|
||||||
|
summary = calculateTextQuotaSummary(ctx, relayInfo, usage)
|
||||||
|
require.Equal(t, 120000, summary.Quota)
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ type TokenCountMeta struct {
|
|||||||
Files []*FileMeta `json:"files,omitempty"` // List of files, each with type and content
|
Files []*FileMeta `json:"files,omitempty"` // List of files, each with type and content
|
||||||
MaxTokens int `json:"max_tokens,omitempty"` // Maximum tokens allowed in the request
|
MaxTokens int `json:"max_tokens,omitempty"` // Maximum tokens allowed in the request
|
||||||
|
|
||||||
ImagePriceRatio float64 `json:"image_ratio,omitempty"` // Ratio for image size, if applicable
|
ImagePriceRatio float64 `json:"image_ratio,omitempty"` // Ratio for image size, if applicable
|
||||||
|
BillingRatios map[string]float64 `json:"billing_ratios,omitempty"` // Validated request multipliers used by pre-consume billing
|
||||||
//IsStreaming bool `json:"is_streaming,omitempty"` // Indicates if the request is streaming
|
//IsStreaming bool `json:"is_streaming,omitempty"` // Indicates if the request is streaming
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user