Skip to content
Toggle navigation
P
Projects
G
Groups
S
Snippets
Help
phsl
/
new-api
This project
Loading...
Sign in
Toggle navigation
Go to a project
Project
Repository
Issues
0
Merge Requests
0
Pipelines
Wiki
Snippets
Members
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Unverified
Commit
d9595831
authored
Jul 10, 2026
by
CaIon
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fix(billing): improve quota handling and error reporting for pre-consume operations
parent
621927f7
Show whitespace changes
Inline
Side-by-side
Showing
12 changed files
with
242 additions
and
57 deletions
+242
-57
common/quota_math.go
+41
-10
common/quota_math_test.go
+18
-0
dto/openai_image.go
+9
-4
pkg/billingexpr/round.go
+3
-5
relay/helper/openai_image_request_test.go
+11
-0
relay/helper/price.go
+26
-24
relay/helper/price_test.go
+100
-2
relay/image_handler.go
+0
-10
service/billing.go
+1
-2
service/quota_saturation_test.go
+4
-0
service/text_quota_test.go
+28
-0
types/request_meta.go
+1
-0
No files found.
common/quota_math.go
View file @
d9595831
...
@@ -16,11 +16,14 @@ const (
...
@@ -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
...
@@ -29,11 +32,20 @@ const (
...
@@ -29,11 +32,20 @@ const (
// 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
// admin_info.quota_saturation. Centralized here so every billing path (consume
// admin_info.quota_saturation. Centralized here so every billing path (consume
// logs, task billing logs, task compensation logs) records the same shape.
// logs, task billing logs, task compensation logs) records the same shape.
...
@@ -58,19 +70,26 @@ func (c *QuotaClamp) AuditMap() map[string]interface{} {
...
@@ -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) {
...
@@ -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) {
...
@@ -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
{
...
...
common/quota_math_test.go
View file @
d9595831
...
@@ -6,6 +6,7 @@ import (
...
@@ -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) {
...
@@ -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
)
{
...
...
dto/openai_image.go
View file @
d9595831
...
@@ -155,14 +155,19 @@ func (i *ImageRequest) GetTokenCountMeta() *types.TokenCountMeta {
...
@@ -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
)},
}
}
}
}
...
...
pkg/billingexpr/round.go
View file @
d9595831
...
@@ -13,9 +13,7 @@ func QuotaRound(f float64) int {
...
@@ -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
)
}
}
relay/helper/openai_image_request_test.go
View file @
d9595831
...
@@ -109,6 +109,16 @@ func TestGetAndValidOpenAIImageRequestNBounds(t *testing.T) {
...
@@ -109,6 +109,16 @@ func TestGetAndValidOpenAIImageRequestNBounds(t *testing.T) {
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"}`
,
wantN
:
1
,
wantN
:
1
,
...
@@ -127,6 +137,7 @@ func TestGetAndValidOpenAIImageRequestNBounds(t *testing.T) {
...
@@ -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"
])
})
})
}
}
...
...
relay/helper/price.go
View file @
d9595831
...
@@ -32,10 +32,6 @@ func modelPriceNotConfiguredError(modelName string, userId int) error {
...
@@ -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
...
@@ -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
...
@@ -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
...
@@ -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
...
@@ -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
...
@@ -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
...
...
relay/helper/price_test.go
View file @
d9595831
...
@@ -10,6 +10,7 @@ import (
...
@@ -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) {
...
@@ -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) {
...
@@ -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
)
}
}
relay/image_handler.go
View file @
d9595831
...
@@ -123,16 +123,6 @@ func ImageHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type
...
@@ -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
}
}
...
...
service/billing.go
View file @
d9595831
...
@@ -19,9 +19,8 @@ const (
...
@@ -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
(),
...
...
service/quota_saturation_test.go
View file @
d9595831
...
@@ -92,6 +92,10 @@ func TestPreConsumeBillingRejectsSaturatedQuotaBeforeDeduction(t *testing.T) {
...
@@ -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
)
}
}
...
...
service/text_quota_test.go
View file @
d9595831
...
@@ -490,3 +490,31 @@ func TestTryTieredSettleNoClampInRange(t *testing.T) {
...
@@ -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
)
}
types/request_meta.go
View file @
d9595831
...
@@ -27,6 +27,7 @@ type TokenCountMeta struct {
...
@@ -27,6 +27,7 @@ type TokenCountMeta struct {
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
}
}
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment