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
Commit
5b03b39d
authored
Mar 16, 2026
by
CaIon
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: enhance tiered billing logic and improve variable handling in pricing calculations
parent
f6c0852d
Show whitespace changes
Inline
Side-by-side
Showing
10 changed files
with
425 additions
and
233 deletions
+425
-233
.gitignore
+2
-1
pkg/billingexpr/compile.go
+53
-6
relay/compatible_handler.go
+6
-11
service/quota.go
+10
-15
service/tiered_settle.go
+57
-0
service/tiered_settle_test.go
+182
-0
web/src/components/table/model-pricing/modal/components/DynamicPricingBreakdown.jsx
+21
-99
web/src/helpers/render.jsx
+65
-81
web/src/helpers/utils.jsx
+26
-18
web/src/pages/Setting/Ratio/components/TieredPricingEditor.jsx
+3
-2
No files found.
.gitignore
View file @
5b03b39d
...
@@ -29,6 +29,6 @@ data/
...
@@ -29,6 +29,6 @@ data/
.gomodcache/
.gomodcache/
.gocache-temp
.gocache-temp
.gopath
.gopath
.test
token_estimator_test.go
token_estimator_test.go
skills-lock.json
skills-lock.json
\ No newline at end of file
pkg/billingexpr/compile.go
View file @
5b03b39d
...
@@ -6,14 +6,20 @@ import (
...
@@ -6,14 +6,20 @@ import (
"sync"
"sync"
"github.com/expr-lang/expr"
"github.com/expr-lang/expr"
"github.com/expr-lang/expr/ast"
"github.com/expr-lang/expr/vm"
"github.com/expr-lang/expr/vm"
)
)
const
maxCacheSize
=
256
const
maxCacheSize
=
256
type
cachedEntry
struct
{
prog
*
vm
.
Program
usedVars
map
[
string
]
bool
}
var
(
var
(
cacheMu
sync
.
RWMutex
cacheMu
sync
.
RWMutex
cache
=
make
(
map
[
string
]
*
vm
.
Program
,
64
)
cache
=
make
(
map
[
string
]
*
cachedEntry
,
64
)
)
)
// compileEnvPrototype is the type-checking prototype used at compile time.
// compileEnvPrototype is the type-checking prototype used at compile time.
...
@@ -67,9 +73,9 @@ func CompileFromCacheByHash(exprStr, hash string) (*vm.Program, error) {
...
@@ -67,9 +73,9 @@ func CompileFromCacheByHash(exprStr, hash string) (*vm.Program, error) {
func
compileFromCacheByHash
(
exprStr
,
hash
string
)
(
*
vm
.
Program
,
error
)
{
func
compileFromCacheByHash
(
exprStr
,
hash
string
)
(
*
vm
.
Program
,
error
)
{
cacheMu
.
RLock
()
cacheMu
.
RLock
()
if
prog
,
ok
:=
cache
[
hash
];
ok
{
if
entry
,
ok
:=
cache
[
hash
];
ok
{
cacheMu
.
RUnlock
()
cacheMu
.
RUnlock
()
return
prog
,
nil
return
entry
.
prog
,
nil
}
}
cacheMu
.
RUnlock
()
cacheMu
.
RUnlock
()
...
@@ -78,20 +84,61 @@ func compileFromCacheByHash(exprStr, hash string) (*vm.Program, error) {
...
@@ -78,20 +84,61 @@ func compileFromCacheByHash(exprStr, hash string) (*vm.Program, error) {
return
nil
,
fmt
.
Errorf
(
"expr compile error: %w"
,
err
)
return
nil
,
fmt
.
Errorf
(
"expr compile error: %w"
,
err
)
}
}
vars
:=
extractUsedVars
(
prog
)
cacheMu
.
Lock
()
cacheMu
.
Lock
()
if
len
(
cache
)
>=
maxCacheSize
{
if
len
(
cache
)
>=
maxCacheSize
{
cache
=
make
(
map
[
string
]
*
vm
.
Program
,
64
)
cache
=
make
(
map
[
string
]
*
cachedEntry
,
64
)
}
}
cache
[
hash
]
=
prog
cache
[
hash
]
=
&
cachedEntry
{
prog
:
prog
,
usedVars
:
vars
}
cacheMu
.
Unlock
()
cacheMu
.
Unlock
()
return
prog
,
nil
return
prog
,
nil
}
}
func
extractUsedVars
(
prog
*
vm
.
Program
)
map
[
string
]
bool
{
vars
:=
make
(
map
[
string
]
bool
)
node
:=
prog
.
Node
()
ast
.
Find
(
node
,
func
(
n
ast
.
Node
)
bool
{
if
id
,
ok
:=
n
.
(
*
ast
.
IdentifierNode
);
ok
{
vars
[
id
.
Value
]
=
true
}
return
false
})
return
vars
}
// UsedVars returns the set of identifier names referenced by an expression.
// The result is cached alongside the compiled program. Returns nil for empty input.
func
UsedVars
(
exprStr
string
)
map
[
string
]
bool
{
if
exprStr
==
""
{
return
nil
}
hash
:=
ExprHashString
(
exprStr
)
cacheMu
.
RLock
()
if
entry
,
ok
:=
cache
[
hash
];
ok
{
cacheMu
.
RUnlock
()
return
entry
.
usedVars
}
cacheMu
.
RUnlock
()
// Compile (and cache) to populate usedVars
if
_
,
err
:=
compileFromCacheByHash
(
exprStr
,
hash
);
err
!=
nil
{
return
nil
}
cacheMu
.
RLock
()
entry
,
ok
:=
cache
[
hash
]
cacheMu
.
RUnlock
()
if
ok
{
return
entry
.
usedVars
}
return
nil
}
// InvalidateCache clears the compiled-expression cache.
// InvalidateCache clears the compiled-expression cache.
// Called when billing rules are updated.
// Called when billing rules are updated.
func
InvalidateCache
()
{
func
InvalidateCache
()
{
cacheMu
.
Lock
()
cacheMu
.
Lock
()
cache
=
make
(
map
[
string
]
*
vm
.
Program
,
64
)
cache
=
make
(
map
[
string
]
*
cachedEntry
,
64
)
cacheMu
.
Unlock
()
cacheMu
.
Unlock
()
}
}
relay/compatible_handler.go
View file @
5b03b39d
...
@@ -238,17 +238,13 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
...
@@ -238,17 +238,13 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
}
}
// Tiered billing: only determines quota, logging continues through normal path
// Tiered billing: only determines quota, logging continues through normal path
isClaudeUsageSemantic
:=
relayInfo
.
GetFinalRequestRelayFormat
()
==
types
.
RelayFormatClaude
var
tieredUsedVars
map
[
string
]
bool
if
snap
:=
relayInfo
.
TieredBillingSnapshot
;
snap
!=
nil
{
tieredUsedVars
=
billingexpr
.
UsedVars
(
snap
.
ExprString
)
}
var
tieredResult
*
billingexpr
.
TieredResult
var
tieredResult
*
billingexpr
.
TieredResult
tieredOk
,
tieredQuota
,
tieredRes
:=
service
.
TryTieredSettle
(
relayInfo
,
billingexpr
.
TokenParams
{
tieredOk
,
tieredQuota
,
tieredRes
:=
service
.
TryTieredSettle
(
relayInfo
,
service
.
BuildTieredTokenParams
(
usage
,
isClaudeUsageSemantic
,
tieredUsedVars
))
P
:
float64
(
usage
.
PromptTokens
),
C
:
float64
(
usage
.
CompletionTokens
),
CR
:
float64
(
usage
.
PromptTokensDetails
.
CachedTokens
),
CC
:
float64
(
usage
.
PromptTokensDetails
.
CachedCreationTokens
-
usage
.
ClaudeCacheCreation1hTokens
),
CC1h
:
float64
(
usage
.
ClaudeCacheCreation1hTokens
),
Img
:
float64
(
usage
.
PromptTokensDetails
.
ImageTokens
),
AI
:
float64
(
usage
.
PromptTokensDetails
.
AudioTokens
),
AO
:
float64
(
usage
.
CompletionTokenDetails
.
AudioTokens
),
})
if
tieredOk
{
if
tieredOk
{
tieredResult
=
tieredRes
tieredResult
=
tieredRes
}
}
...
@@ -354,7 +350,6 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
...
@@ -354,7 +350,6 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
var
audioInputQuota
decimal
.
Decimal
var
audioInputQuota
decimal
.
Decimal
var
audioInputPrice
float64
var
audioInputPrice
float64
isClaudeUsageSemantic
:=
relayInfo
.
GetFinalRequestRelayFormat
()
==
types
.
RelayFormatClaude
if
!
relayInfo
.
PriceData
.
UsePrice
{
if
!
relayInfo
.
PriceData
.
UsePrice
{
baseTokens
:=
dPromptTokens
baseTokens
:=
dPromptTokens
// 减去 cached tokens
// 减去 cached tokens
...
...
service/quota.go
View file @
5b03b39d
...
@@ -256,14 +256,12 @@ func PostClaudeConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
...
@@ -256,14 +256,12 @@ func PostClaudeConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
ObserveChannelAffinityUsageCacheByRelayFormat
(
ctx
,
usage
,
relayInfo
.
GetFinalRequestRelayFormat
())
ObserveChannelAffinityUsageCacheByRelayFormat
(
ctx
,
usage
,
relayInfo
.
GetFinalRequestRelayFormat
())
}
}
var
tieredUsedVars
map
[
string
]
bool
if
snap
:=
relayInfo
.
TieredBillingSnapshot
;
snap
!=
nil
{
tieredUsedVars
=
billingexpr
.
UsedVars
(
snap
.
ExprString
)
}
var
tieredResult
*
billingexpr
.
TieredResult
var
tieredResult
*
billingexpr
.
TieredResult
tieredOk
,
tieredQuota
,
tieredRes
:=
TryTieredSettle
(
relayInfo
,
billingexpr
.
TokenParams
{
tieredOk
,
tieredQuota
,
tieredRes
:=
TryTieredSettle
(
relayInfo
,
BuildTieredTokenParams
(
usage
,
true
,
tieredUsedVars
))
P
:
float64
(
usage
.
PromptTokens
),
C
:
float64
(
usage
.
CompletionTokens
),
CR
:
float64
(
usage
.
PromptTokensDetails
.
CachedTokens
),
CC
:
float64
(
usage
.
PromptTokensDetails
.
CachedCreationTokens
-
usage
.
ClaudeCacheCreation1hTokens
),
CC1h
:
float64
(
usage
.
ClaudeCacheCreation1hTokens
),
})
if
tieredOk
{
if
tieredOk
{
tieredResult
=
tieredRes
tieredResult
=
tieredRes
}
}
...
@@ -394,14 +392,12 @@ func CalcOpenRouterCacheCreateTokens(usage dto.Usage, priceData types.PriceData)
...
@@ -394,14 +392,12 @@ func CalcOpenRouterCacheCreateTokens(usage dto.Usage, priceData types.PriceData)
func
PostAudioConsumeQuota
(
ctx
*
gin
.
Context
,
relayInfo
*
relaycommon
.
RelayInfo
,
usage
*
dto
.
Usage
,
extraContent
string
)
{
func
PostAudioConsumeQuota
(
ctx
*
gin
.
Context
,
relayInfo
*
relaycommon
.
RelayInfo
,
usage
*
dto
.
Usage
,
extraContent
string
)
{
var
tieredUsedVars
map
[
string
]
bool
if
snap
:=
relayInfo
.
TieredBillingSnapshot
;
snap
!=
nil
{
tieredUsedVars
=
billingexpr
.
UsedVars
(
snap
.
ExprString
)
}
var
tieredResult
*
billingexpr
.
TieredResult
var
tieredResult
*
billingexpr
.
TieredResult
tieredOk
,
tieredQuota
,
tieredRes
:=
TryTieredSettle
(
relayInfo
,
billingexpr
.
TokenParams
{
tieredOk
,
tieredQuota
,
tieredRes
:=
TryTieredSettle
(
relayInfo
,
BuildTieredTokenParams
(
usage
,
false
,
tieredUsedVars
))
P
:
float64
(
usage
.
PromptTokens
),
C
:
float64
(
usage
.
CompletionTokens
),
CR
:
float64
(
usage
.
PromptTokensDetails
.
CachedTokens
),
AI
:
float64
(
usage
.
PromptTokensDetails
.
AudioTokens
),
AO
:
float64
(
usage
.
CompletionTokenDetails
.
AudioTokens
),
})
if
tieredOk
{
if
tieredOk
{
tieredResult
=
tieredRes
tieredResult
=
tieredRes
}
}
...
@@ -659,4 +655,3 @@ func checkAndSendSubscriptionQuotaNotify(relayInfo *relaycommon.RelayInfo) {
...
@@ -659,4 +655,3 @@ func checkAndSendSubscriptionQuotaNotify(relayInfo *relaycommon.RelayInfo) {
}
}
})
})
}
}
service/tiered_settle.go
View file @
5b03b39d
package
service
package
service
import
(
import
(
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/pkg/billingexpr"
"github.com/QuantumNous/new-api/pkg/billingexpr"
relaycommon
"github.com/QuantumNous/new-api/relay/common"
relaycommon
"github.com/QuantumNous/new-api/relay/common"
)
)
...
@@ -8,6 +9,62 @@ import (
...
@@ -8,6 +9,62 @@ import (
// TieredResultWrapper wraps billingexpr.TieredResult for use at the service layer.
// TieredResultWrapper wraps billingexpr.TieredResult for use at the service layer.
type
TieredResultWrapper
=
billingexpr
.
TieredResult
type
TieredResultWrapper
=
billingexpr
.
TieredResult
// BuildTieredTokenParams constructs billingexpr.TokenParams from a dto.Usage,
// normalizing P and C so they mean "tokens not separately priced by the
// expression". Sub-categories (cache, image, audio) are only subtracted
// when the expression references them via their own variable.
//
// GPT-format APIs report prompt_tokens / completion_tokens as totals that
// include all sub-categories (cache, image, audio). Claude-format APIs
// report them as text-only. This function normalizes to text-only when
// sub-categories are separately priced.
func
BuildTieredTokenParams
(
usage
*
dto
.
Usage
,
isClaudeUsageSemantic
bool
,
usedVars
map
[
string
]
bool
)
billingexpr
.
TokenParams
{
p
:=
float64
(
usage
.
PromptTokens
)
c
:=
float64
(
usage
.
CompletionTokens
)
cr
:=
float64
(
usage
.
PromptTokensDetails
.
CachedTokens
)
ccTotal
:=
float64
(
usage
.
PromptTokensDetails
.
CachedCreationTokens
)
cc1h
:=
float64
(
usage
.
ClaudeCacheCreation1hTokens
)
img
:=
float64
(
usage
.
PromptTokensDetails
.
ImageTokens
)
ai
:=
float64
(
usage
.
PromptTokensDetails
.
AudioTokens
)
ao
:=
float64
(
usage
.
CompletionTokenDetails
.
AudioTokens
)
if
!
isClaudeUsageSemantic
{
if
usedVars
[
"cr"
]
||
usedVars
[
"cache_read_tokens"
]
{
p
-=
cr
}
if
usedVars
[
"cc"
]
||
usedVars
[
"cc1h"
]
||
usedVars
[
"cache_create_tokens"
]
||
usedVars
[
"cache_create_1h_tokens"
]
{
p
-=
ccTotal
}
if
usedVars
[
"img"
]
||
usedVars
[
"image_tokens"
]
{
p
-=
img
}
if
usedVars
[
"ai"
]
||
usedVars
[
"audio_input_tokens"
]
{
p
-=
ai
}
if
usedVars
[
"ao"
]
||
usedVars
[
"audio_output_tokens"
]
{
c
-=
ao
}
}
if
p
<
0
{
p
=
0
}
if
c
<
0
{
c
=
0
}
return
billingexpr
.
TokenParams
{
P
:
p
,
C
:
c
,
CR
:
cr
,
CC
:
ccTotal
-
cc1h
,
CC1h
:
cc1h
,
Img
:
img
,
AI
:
ai
,
AO
:
ao
,
}
}
// TryTieredSettle checks if the request uses tiered_expr billing and, if so,
// TryTieredSettle checks if the request uses tiered_expr billing and, if so,
// computes the actual quota using the frozen BillingSnapshot. Returns:
// computes the actual quota using the frozen BillingSnapshot. Returns:
// - ok=true, quota, result when tiered billing applies
// - ok=true, quota, result when tiered billing applies
...
...
service/tiered_settle_test.go
View file @
5b03b39d
package
service
package
service
import
(
import
(
"math"
"testing"
"testing"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/pkg/billingexpr"
"github.com/QuantumNous/new-api/pkg/billingexpr"
relaycommon
"github.com/QuantumNous/new-api/relay/common"
relaycommon
"github.com/QuantumNous/new-api/relay/common"
)
)
...
@@ -405,3 +407,183 @@ func TestTryTieredSettle_ErrorFallbackToEstimatedQuotaAfterGroup(t *testing.T) {
...
@@ -405,3 +407,183 @@ func TestTryTieredSettle_ErrorFallbackToEstimatedQuotaAfterGroup(t *testing.T) {
t
.
Fatal
(
"result should be nil on error fallback"
)
t
.
Fatal
(
"result should be nil on error fallback"
)
}
}
}
}
// ---------------------------------------------------------------------------
// BuildTieredTokenParams: token normalization and ratio parity tests
// ---------------------------------------------------------------------------
func
tieredQuota
(
exprStr
string
,
usage
*
dto
.
Usage
,
isClaudeSemantic
bool
,
groupRatio
float64
)
float64
{
usedVars
:=
billingexpr
.
UsedVars
(
exprStr
)
params
:=
BuildTieredTokenParams
(
usage
,
isClaudeSemantic
,
usedVars
)
cost
,
_
,
_
:=
billingexpr
.
RunExpr
(
exprStr
,
params
)
return
cost
/
1
_000_000
*
testQuotaPerUnit
*
groupRatio
}
func
ratioQuota
(
usage
*
dto
.
Usage
,
isClaudeSemantic
bool
,
modelRatio
,
completionRatio
,
cacheRatio
,
imageRatio
,
groupRatio
float64
)
float64
{
baseTokens
:=
float64
(
usage
.
PromptTokens
)
cacheTokens
:=
float64
(
usage
.
PromptTokensDetails
.
CachedTokens
)
ccTokens
:=
float64
(
usage
.
PromptTokensDetails
.
CachedCreationTokens
)
imgTokens
:=
float64
(
usage
.
PromptTokensDetails
.
ImageTokens
)
if
!
isClaudeSemantic
{
baseTokens
-=
cacheTokens
baseTokens
-=
ccTokens
baseTokens
-=
imgTokens
}
promptQuota
:=
baseTokens
+
cacheTokens
*
cacheRatio
+
imgTokens
*
imageRatio
completionQuota
:=
float64
(
usage
.
CompletionTokens
)
*
completionRatio
return
(
promptQuota
+
completionQuota
)
*
modelRatio
*
groupRatio
}
func
TestBuildTieredTokenParams_GPT_WithCache
(
t
*
testing
.
T
)
{
usage
:=
&
dto
.
Usage
{
PromptTokens
:
1000
,
CompletionTokens
:
500
,
PromptTokensDetails
:
dto
.
InputTokenDetails
{
CachedTokens
:
200
,
TextTokens
:
800
,
},
}
expr
:=
`tier("base", p * 2.5 + c * 15 + cr * 0.25)`
got
:=
tieredQuota
(
expr
,
usage
,
false
,
1.0
)
// P=800, C=500, CR=200 → (800*2.5 + 500*15 + 200*0.25) * 0.5 = 4775
want
:=
4775.0
if
math
.
Abs
(
got
-
want
)
>
0.01
{
t
.
Fatalf
(
"quota = %f, want %f"
,
got
,
want
)
}
}
func
TestBuildTieredTokenParams_GPT_NoCacheVar
(
t
*
testing
.
T
)
{
usage
:=
&
dto
.
Usage
{
PromptTokens
:
1000
,
CompletionTokens
:
500
,
PromptTokensDetails
:
dto
.
InputTokenDetails
{
CachedTokens
:
200
,
TextTokens
:
800
,
},
}
expr
:=
`tier("base", p * 2.5 + c * 15)`
got
:=
tieredQuota
(
expr
,
usage
,
false
,
1.0
)
// No cr → P=1000 (cache stays in P), C=500 → (1000*2.5 + 500*15) * 0.5 = 5000
want
:=
5000.0
if
math
.
Abs
(
got
-
want
)
>
0.01
{
t
.
Fatalf
(
"quota = %f, want %f"
,
got
,
want
)
}
}
func
TestBuildTieredTokenParams_GPT_WithImage
(
t
*
testing
.
T
)
{
usage
:=
&
dto
.
Usage
{
PromptTokens
:
1000
,
CompletionTokens
:
500
,
PromptTokensDetails
:
dto
.
InputTokenDetails
{
ImageTokens
:
200
,
TextTokens
:
800
,
},
}
expr
:=
`tier("base", p * 2 + c * 8 + img * 2.5)`
got
:=
tieredQuota
(
expr
,
usage
,
false
,
1.0
)
// P=800, C=500, Img=200 → (800*2 + 500*8 + 200*2.5) * 0.5 = 3050
want
:=
3050.0
if
math
.
Abs
(
got
-
want
)
>
0.01
{
t
.
Fatalf
(
"quota = %f, want %f"
,
got
,
want
)
}
}
func
TestBuildTieredTokenParams_Claude_WithCache
(
t
*
testing
.
T
)
{
usage
:=
&
dto
.
Usage
{
PromptTokens
:
800
,
CompletionTokens
:
500
,
PromptTokensDetails
:
dto
.
InputTokenDetails
{
CachedTokens
:
200
,
TextTokens
:
800
,
},
}
expr
:=
`tier("base", p * 3 + c * 15 + cr * 0.3)`
got
:=
tieredQuota
(
expr
,
usage
,
true
,
1.0
)
// Claude: P=800 (no subtraction), C=500, CR=200 → (800*3 + 500*15 + 200*0.3) * 0.5 = 4980
want
:=
4980.0
if
math
.
Abs
(
got
-
want
)
>
0.01
{
t
.
Fatalf
(
"quota = %f, want %f"
,
got
,
want
)
}
}
func
TestBuildTieredTokenParams_GPT_AudioOutput
(
t
*
testing
.
T
)
{
usage
:=
&
dto
.
Usage
{
PromptTokens
:
1000
,
CompletionTokens
:
600
,
CompletionTokenDetails
:
dto
.
OutputTokenDetails
{
AudioTokens
:
100
,
TextTokens
:
500
,
},
}
expr
:=
`tier("base", p * 2 + c * 10 + ao * 50)`
got
:=
tieredQuota
(
expr
,
usage
,
false
,
1.0
)
// C=600-100=500, AO=100 → (1000*2 + 500*10 + 100*50) * 0.5 = 6000
want
:=
6000.0
if
math
.
Abs
(
got
-
want
)
>
0.01
{
t
.
Fatalf
(
"quota = %f, want %f"
,
got
,
want
)
}
}
func
TestBuildTieredTokenParams_GPT_AudioOutputNoVar
(
t
*
testing
.
T
)
{
usage
:=
&
dto
.
Usage
{
PromptTokens
:
1000
,
CompletionTokens
:
600
,
CompletionTokenDetails
:
dto
.
OutputTokenDetails
{
AudioTokens
:
100
,
TextTokens
:
500
,
},
}
expr
:=
`tier("base", p * 2 + c * 10)`
got
:=
tieredQuota
(
expr
,
usage
,
false
,
1.0
)
// No ao → C=600 (audio stays in C) → (1000*2 + 600*10) * 0.5 = 4000
want
:=
4000.0
if
math
.
Abs
(
got
-
want
)
>
0.01
{
t
.
Fatalf
(
"quota = %f, want %f"
,
got
,
want
)
}
}
func
TestBuildTieredTokenParams_ParityWithRatio
(
t
*
testing
.
T
)
{
// GPT-5.4 prices: input=$2.5, output=$15, cacheRead=$0.25
// Ratio equivalents: modelRatio=1.25, completionRatio=6, cacheRatio=0.1
usage
:=
&
dto
.
Usage
{
PromptTokens
:
10000
,
CompletionTokens
:
2000
,
PromptTokensDetails
:
dto
.
InputTokenDetails
{
CachedTokens
:
3000
,
TextTokens
:
7000
,
},
}
expr
:=
`tier("base", p * 2.5 + c * 15 + cr * 0.25)`
for
_
,
gr
:=
range
[]
float64
{
1.0
,
1.5
,
2.0
,
0.5
}
{
tq
:=
tieredQuota
(
expr
,
usage
,
false
,
gr
)
rq
:=
ratioQuota
(
usage
,
false
,
1.25
,
6
,
0.1
,
0
,
gr
)
if
math
.
Abs
(
tq
-
rq
)
>
0.01
{
t
.
Fatalf
(
"groupRatio=%v: tiered=%f ratio=%f (mismatch)"
,
gr
,
tq
,
rq
)
}
}
}
func
TestBuildTieredTokenParams_ParityWithRatio_Image
(
t
*
testing
.
T
)
{
// gpt-image-1-mini prices: input=$2, output=$8, image=$2.5
// Ratio equivalents: modelRatio=1, completionRatio=4, imageRatio=1.25
usage
:=
&
dto
.
Usage
{
PromptTokens
:
5000
,
CompletionTokens
:
4000
,
PromptTokensDetails
:
dto
.
InputTokenDetails
{
ImageTokens
:
1000
,
TextTokens
:
4000
,
},
}
expr
:=
`tier("base", p * 2 + c * 8 + img * 2.5)`
tq
:=
tieredQuota
(
expr
,
usage
,
false
,
1.0
)
rq
:=
ratioQuota
(
usage
,
false
,
1.0
,
4
,
0
,
1.25
,
1.0
)
if
math
.
Abs
(
tq
-
rq
)
>
0.01
{
t
.
Fatalf
(
"tiered=%f ratio=%f (mismatch)"
,
tq
,
rq
)
}
}
web/src/components/table/model-pricing/modal/components/DynamicPricingBreakdown.jsx
View file @
5b03b39d
...
@@ -20,6 +20,7 @@ For commercial licensing, please contact support@quantumnous.com
...
@@ -20,6 +20,7 @@ For commercial licensing, please contact support@quantumnous.com
import
React
from
'react'
;
import
React
from
'react'
;
import
{
Card
,
Avatar
,
Tag
,
Table
,
Typography
}
from
'@douyinfe/semi-ui'
;
import
{
Card
,
Avatar
,
Tag
,
Table
,
Typography
}
from
'@douyinfe/semi-ui'
;
import
{
IconPriceTag
}
from
'@douyinfe/semi-icons'
;
import
{
IconPriceTag
}
from
'@douyinfe/semi-icons'
;
import
{
parseTiersFromExpr
}
from
'../../../../../helpers'
;
import
{
import
{
splitBillingExprAndRequestRules
,
splitBillingExprAndRequestRules
,
tryParseRequestRuleExpr
,
tryParseRequestRuleExpr
,
...
@@ -36,15 +37,6 @@ const { Text } = Typography;
...
@@ -36,15 +37,6 @@ const { Text } = Typography;
const
PRICE_SUFFIX
=
'$/1M tokens'
;
const
PRICE_SUFFIX
=
'$/1M tokens'
;
function
unitCostToPrice
(
uc
)
{
return
Number
(
uc
)
||
0
;
}
function
formatPrice
(
uc
)
{
const
p
=
unitCostToPrice
(
uc
);
return
p
?
`$
${
p
.
toFixed
(
4
)}
`
:
'-'
;
}
const
VAR_LABELS
=
{
p
:
'输入'
,
c
:
'输出'
};
const
VAR_LABELS
=
{
p
:
'输入'
,
c
:
'输出'
};
const
OP_LABELS
=
{
'<'
:
'<'
,
'<='
:
'≤'
,
'>'
:
'>'
,
'>='
:
'≥'
};
const
OP_LABELS
=
{
'<'
:
'<'
,
'<='
:
'≤'
,
'>'
:
'>'
,
'>='
:
'≥'
};
const
TIME_FUNC_LABELS
=
{
hour
:
'小时'
,
minute
:
'分钟'
,
weekday
:
'星期'
,
month
:
'月份'
,
day
:
'日期'
};
const
TIME_FUNC_LABELS
=
{
hour
:
'小时'
,
minute
:
'分钟'
,
weekday
:
'星期'
,
month
:
'月份'
,
day
:
'日期'
};
...
@@ -71,54 +63,6 @@ function formatConditionSummary(conditions, t) {
...
@@ -71,54 +63,6 @@ function formatConditionSummary(conditions, t) {
.
join
(
' && '
);
.
join
(
' && '
);
}
}
function
tryParseTiers
(
baseExpr
)
{
if
(
!
baseExpr
)
return
null
;
try
{
const
cacheVars
=
[
'cr'
,
'cc'
,
'cc1h'
];
const
optCache
=
cacheVars
.
map
((
v
)
=>
`(?:\\s*\\+\\s*
${
v
}
\\s*\\*\\s*([\\d.eE+-]+))?`
).
join
(
''
);
const
bodyPat
=
`p\\s*\\*\\s*([\\d.eE+-]+)\\s*\\+\\s*c\\s*\\*\\s*([\\d.eE+-]+)
${
optCache
}
`
;
const
singleRe
=
new
RegExp
(
`^tier\\("([^"]*)",\\s*
${
bodyPat
}
\\)$`
);
const
simple
=
baseExpr
.
match
(
singleRe
);
if
(
simple
)
{
return
[{
label
:
simple
[
1
],
conditions
:
[],
inputPrice
:
unitCostToPrice
(
Number
(
simple
[
2
])),
outputPrice
:
unitCostToPrice
(
Number
(
simple
[
3
])),
cacheReadPrice
:
simple
[
4
]
?
unitCostToPrice
(
Number
(
simple
[
4
]))
:
null
,
cacheCreatePrice
:
simple
[
5
]
?
unitCostToPrice
(
Number
(
simple
[
5
]))
:
null
,
cacheCreate1hPrice
:
simple
[
6
]
?
unitCostToPrice
(
Number
(
simple
[
6
]))
:
null
,
}];
}
const
condGroup
=
`((?:(?:p|c)\\s*(?:<|<=|>|>=)\\s*[\\d.eE+]+)(?:\\s*&&\\s*(?:p|c)\\s*(?:<|<=|>|>=)\\s*[\\d.eE+]+)*)`
;
const
tierRe
=
new
RegExp
(
`(?:
${
condGroup
}
\\s*\\?\\s*)?tier\\("([^"]*)",\\s*
${
bodyPat
}
\\)`
,
'g'
);
const
tiers
=
[];
let
match
;
while
((
match
=
tierRe
.
exec
(
baseExpr
))
!==
null
)
{
const
condStr
=
match
[
1
]
||
''
;
const
conditions
=
[];
if
(
condStr
)
{
for
(
const
cp
of
condStr
.
split
(
/
\s
*&&
\s
*/
))
{
const
cm
=
cp
.
trim
().
match
(
/^
(
p|c
)\s
*
(
<|<=|>|>=
)\s
*
([\d
.eE+
]
+
)
$/
);
if
(
cm
)
conditions
.
push
({
var
:
cm
[
1
],
op
:
cm
[
2
],
value
:
Number
(
cm
[
3
])
});
}
}
tiers
.
push
({
label
:
match
[
2
],
conditions
,
inputPrice
:
unitCostToPrice
(
Number
(
match
[
3
])),
outputPrice
:
unitCostToPrice
(
Number
(
match
[
4
])),
cacheReadPrice
:
match
[
5
]
?
unitCostToPrice
(
Number
(
match
[
5
]))
:
null
,
cacheCreatePrice
:
match
[
6
]
?
unitCostToPrice
(
Number
(
match
[
6
]))
:
null
,
cacheCreate1hPrice
:
match
[
7
]
?
unitCostToPrice
(
Number
(
match
[
7
]))
:
null
,
});
}
return
tiers
.
length
>
0
?
tiers
:
null
;
}
catch
{
return
null
;
}
}
function
describeCondition
(
cond
,
t
)
{
function
describeCondition
(
cond
,
t
)
{
if
(
cond
.
source
===
SOURCE_TIME
)
{
if
(
cond
.
source
===
SOURCE_TIME
)
{
...
@@ -147,7 +91,7 @@ export default function DynamicPricingBreakdown({ billingExpr, t }) {
...
@@ -147,7 +91,7 @@ export default function DynamicPricingBreakdown({ billingExpr, t }) {
const
{
billingExpr
:
baseExpr
,
requestRuleExpr
:
ruleExpr
}
=
const
{
billingExpr
:
baseExpr
,
requestRuleExpr
:
ruleExpr
}
=
splitBillingExprAndRequestRules
(
billingExpr
||
''
);
splitBillingExprAndRequestRules
(
billingExpr
||
''
);
const
tiers
=
tryParseTiers
(
baseExpr
);
const
tiers
=
parseTiersFromExpr
(
baseExpr
);
const
ruleGroups
=
tryParseRequestRuleExpr
(
ruleExpr
||
''
);
const
ruleGroups
=
tryParseRequestRuleExpr
(
ruleExpr
||
''
);
const
hasTiers
=
tiers
&&
tiers
.
length
>
0
;
const
hasTiers
=
tiers
&&
tiers
.
length
>
0
;
...
@@ -169,6 +113,17 @@ export default function DynamicPricingBreakdown({ billingExpr, t }) {
...
@@ -169,6 +113,17 @@ export default function DynamicPricingBreakdown({ billingExpr, t }) {
);
);
}
}
const
priceFields
=
[
[
'inputPrice'
,
'输入价格'
],
[
'outputPrice'
,
'补全价格'
],
[
'cacheReadPrice'
,
'缓存读取'
],
[
'cacheCreatePrice'
,
'缓存创建'
],
[
'cacheCreate1hPrice'
,
'缓存创建-1h'
],
[
'imagePrice'
,
'图片输入'
],
[
'audioInputPrice'
,
'音频输入'
],
[
'audioOutputPrice'
,
'音频输出'
],
];
const
tierColumns
=
[
const
tierColumns
=
[
{
{
title
:
t
(
'档位'
),
title
:
t
(
'档位'
),
...
@@ -182,54 +137,21 @@ export default function DynamicPricingBreakdown({ billingExpr, t }) {
...
@@ -182,54 +137,21 @@ export default function DynamicPricingBreakdown({ billingExpr, t }) {
</
div
>
</
div
>
),
),
},
},
{
...
priceFields
title
:
`
${
t
(
'输入价格'
)}
(
${
PRICE_SUFFIX
}
)`
,
.
filter
(([
field
])
=>
hasTiers
&&
tiers
.
some
((
tier
)
=>
tier
[
field
]
>
0
))
dataIndex
:
'inputPrice'
,
.
map
(([
field
,
label
])
=>
({
render
:
(
v
)
=>
<
Text
strong
>
$
{
v
.
toFixed
(
4
)
}
</
Text
>,
title
:
`
${
t
(
label
)}
(
${
PRICE_SUFFIX
}
)`
,
},
dataIndex
:
field
,
{
render
:
(
v
)
=>
v
>
0
?
<
Text
strong
>
$
{
v
.
toFixed
(
4
)
}
</
Text
>
:
'-'
,
title
:
`
${
t
(
'输出价格'
)}
(
${
PRICE_SUFFIX
}
)`
,
})),
dataIndex
:
'outputPrice'
,
render
:
(
v
)
=>
<
Text
strong
>
$
{
v
.
toFixed
(
4
)
}
</
Text
>,
},
];
];
const
hasCacheRead
=
hasTiers
&&
tiers
.
some
((
tier
)
=>
tier
.
cacheReadPrice
!=
null
);
const
hasCacheCreate
=
hasTiers
&&
tiers
.
some
((
tier
)
=>
tier
.
cacheCreatePrice
!=
null
);
const
hasCache1h
=
hasTiers
&&
tiers
.
some
((
tier
)
=>
tier
.
cacheCreate1hPrice
!=
null
);
if
(
hasCacheRead
)
{
tierColumns
.
push
({
title
:
`
${
t
(
'缓存读取'
)}
(
${
PRICE_SUFFIX
}
)`
,
dataIndex
:
'cacheReadPrice'
,
render
:
(
v
)
=>
v
!=
null
?
<
Text
>
$
{
v
.
toFixed
(
4
)
}
</
Text
>
:
'-'
,
});
}
if
(
hasCacheCreate
)
{
tierColumns
.
push
({
title
:
`
${
t
(
'缓存创建'
)}
(
${
PRICE_SUFFIX
}
)`
,
dataIndex
:
'cacheCreatePrice'
,
render
:
(
v
)
=>
v
!=
null
?
<
Text
>
$
{
v
.
toFixed
(
4
)
}
</
Text
>
:
'-'
,
});
}
if
(
hasCache1h
)
{
tierColumns
.
push
({
title
:
`
${
t
(
'缓存创建-1h'
)}
(
${
PRICE_SUFFIX
}
)`
,
dataIndex
:
'cacheCreate1hPrice'
,
render
:
(
v
)
=>
v
!=
null
?
<
Text
>
$
{
v
.
toFixed
(
4
)
}
</
Text
>
:
'-'
,
});
}
const
tierData
=
hasTiers
const
tierData
=
hasTiers
?
tiers
.
map
((
tier
,
i
)
=>
({
?
tiers
.
map
((
tier
,
i
)
=>
({
key
:
`tier-
${
i
}
`
,
key
:
`tier-
${
i
}
`
,
label
:
tier
.
label
,
label
:
tier
.
label
,
condSummary
:
formatConditionSummary
(
tier
.
conditions
,
t
),
condSummary
:
formatConditionSummary
(
tier
.
conditions
,
t
),
inputPrice
:
tier
.
inputPrice
,
...
Object
.
fromEntries
(
priceFields
.
map
(([
field
])
=>
[
field
,
tier
[
field
]
||
0
])),
outputPrice
:
tier
.
outputPrice
,
cacheReadPrice
:
tier
.
cacheReadPrice
,
cacheCreatePrice
:
tier
.
cacheCreatePrice
,
cacheCreate1hPrice
:
tier
.
cacheCreate1hPrice
,
}))
}))
:
[];
:
[];
...
...
web/src/helpers/render.jsx
View file @
5b03b39d
...
@@ -2210,24 +2210,47 @@ export function renderLogContent(opts) {
...
@@ -2210,24 +2210,47 @@ export function renderLogContent(opts) {
}
}
}
}
function
parseTiersFromExpr
(
exprStr
)
{
const
TIER_VAR_KEYS
=
[
'p'
,
'c'
,
'cr'
,
'cc'
,
'cc1h'
,
'img'
,
'ai'
,
'ao'
];
const
TIER_VAR_TO_FIELD
=
{
p
:
'inputPrice'
,
c
:
'outputPrice'
,
cr
:
'cacheReadPrice'
,
cc
:
'cacheCreatePrice'
,
cc1h
:
'cacheCreate1hPrice'
,
img
:
'imagePrice'
,
ai
:
'audioInputPrice'
,
ao
:
'audioOutputPrice'
,
};
function
parseTierBody
(
bodyStr
)
{
const
coeffs
=
{};
const
re
=
new
RegExp
(
`\\b(
${
TIER_VAR_KEYS
.
join
(
'|'
)}
)\\s*\\*\\s*([\\d.eE+-]+)`
,
'g'
);
let
m
;
while
((
m
=
re
.
exec
(
bodyStr
))
!==
null
)
{
if
(
!
(
m
[
1
]
in
coeffs
))
coeffs
[
m
[
1
]]
=
Number
(
m
[
2
]);
}
const
tier
=
{};
for
(
const
[
varName
,
field
]
of
Object
.
entries
(
TIER_VAR_TO_FIELD
))
{
tier
[
field
]
=
coeffs
[
varName
]
||
0
;
}
return
tier
;
}
export
function
parseTiersFromExpr
(
exprStr
)
{
if
(
!
exprStr
)
return
[];
if
(
!
exprStr
)
return
[];
try
{
try
{
const
cacheVars
=
[
'cr'
,
'cc'
,
'cc1h'
];
const
condGroup
=
`((?:(?:p|c)\\s*(?:<|<=|>|>=)\\s*[\\d.eE+]+)(?:\\s*&&\\s*(?:p|c)\\s*(?:<|<=|>|>=)\\s*[\\d.eE+]+)*)`
;
const
optCache
=
cacheVars
.
map
((
v
)
=>
`(?:\\s*\\+\\s*
${
v
}
\\s*\\*\\s*([\\d.eE+-]+))?`
).
join
(
''
);
const
tierRe
=
new
RegExp
(
`(?:
${
condGroup
}
\\s*\\?\\s*)?tier\\("([^"]*)",\\s*([^)]+)\\)`
,
'g'
);
const
bodyPat
=
`p\\s*\\*\\s*([\\d.eE+-]+)\\s*\\+\\s*c\\s*\\*\\s*([\\d.eE+-]+)
${
optCache
}
`
;
const
tierRe
=
new
RegExp
(
`tier\\("([^"]*)",\\s*
${
bodyPat
}
\\)`
,
'g'
);
const
tiers
=
[];
const
tiers
=
[];
let
m
;
let
m
;
while
((
m
=
tierRe
.
exec
(
exprStr
))
!==
null
)
{
while
((
m
=
tierRe
.
exec
(
exprStr
))
!==
null
)
{
tiers
.
push
({
const
condStr
=
m
[
1
]
||
''
;
label
:
m
[
1
],
const
conditions
=
[];
inputPrice
:
Number
(
m
[
2
]),
if
(
condStr
)
{
outputPrice
:
Number
(
m
[
3
]),
for
(
const
cp
of
condStr
.
split
(
/
\s
*&&
\s
*/
))
{
cacheReadPrice
:
m
[
4
]
?
Number
(
m
[
4
])
:
0
,
const
cm
=
cp
.
trim
().
match
(
/^
(
p|c
)\s
*
(
<|<=|>|>=
)\s
*
([\d
.eE+
]
+
)
$/
);
cacheCreatePrice
:
m
[
5
]
?
Number
(
m
[
5
])
:
0
,
if
(
cm
)
conditions
.
push
({
var
:
cm
[
1
],
op
:
cm
[
2
],
value
:
Number
(
cm
[
3
])
});
cacheCreate1hPrice
:
m
[
6
]
?
Number
(
m
[
6
])
:
0
,
}
});
}
const
tier
=
parseTierBody
(
m
[
3
]);
tier
.
label
=
m
[
2
];
tier
.
conditions
=
conditions
;
tiers
.
push
(
tier
);
}
}
return
tiers
;
return
tiers
;
}
catch
{
}
catch
{
...
@@ -2258,44 +2281,23 @@ export function renderTieredModelPrice(opts) {
...
@@ -2258,44 +2281,23 @@ export function renderTieredModelPrice(opts) {
const
{
symbol
,
rate
}
=
getCurrencyConfig
();
const
{
symbol
,
rate
}
=
getCurrencyConfig
();
const
gr
=
groupRatio
||
1
;
const
gr
=
groupRatio
||
1
;
const
inputCost
=
(
inputTokens
/
1000000
)
*
tier
.
inputPrice
;
const
priceLines
=
[
const
outputCost
=
(
completionTokens
/
1000000
)
*
tier
.
outputPrice
;
[
'inputPrice'
,
'输入价格'
],
const
cacheReadCost
=
(
cacheTokens
/
1000000
)
*
tier
.
cacheReadPrice
;
[
'outputPrice'
,
'补全价格'
],
const
hasSplitCacheCreation
=
cacheCreationTokens5m
>
0
||
cacheCreationTokens1h
>
0
;
[
'cacheReadPrice'
,
'缓存读取价格'
],
let
cacheCreateCost
=
0
;
[
'cacheCreatePrice'
,
'缓存创建价格'
],
if
(
hasSplitCacheCreation
)
{
[
'cacheCreate1hPrice'
,
'1h缓存创建价格'
],
cacheCreateCost
=
(
cacheCreationTokens5m
/
1000000
)
*
tier
.
cacheCreatePrice
[
'imagePrice'
,
'图片输入价格'
],
+
(
cacheCreationTokens1h
/
1000000
)
*
tier
.
cacheCreate1hPrice
;
[
'audioInputPrice'
,
'音频输入价格'
],
}
else
if
(
cacheCreationTokens
>
0
)
{
[
'audioOutputPrice'
,
'音频输出价格'
],
cacheCreateCost
=
(
cacheCreationTokens
/
1000000
)
*
tier
.
cacheCreatePrice
;
];
}
const
totalBeforeGroup
=
inputCost
+
outputCost
+
cacheReadCost
+
cacheCreateCost
;
const
total
=
totalBeforeGroup
*
gr
;
const
lines
=
[
const
lines
=
[
buildBillingText
(
'命中档位:{{tier}}'
,
{
tier
:
matchedTier
||
tier
.
label
}),
buildBillingText
(
'命中档位:{{tier}}'
,
{
tier
:
matchedTier
||
tier
.
label
}),
buildBillingPriceText
(
'输入价格:{{symbol}}{{price}} / 1M tokens'
,
{
symbol
,
usdAmount
:
tier
.
inputPrice
,
rate
}),
...
priceLines
buildBillingPriceText
(
'输出价格:{{symbol}}{{price}} / 1M tokens'
,
{
symbol
,
usdAmount
:
tier
.
outputPrice
,
rate
}),
.
filter
(([
field
])
=>
tier
[
field
]
>
0
)
cacheTokens
>
0
&&
tier
.
cacheReadPrice
>
0
.
map
(([
field
,
label
])
=>
?
buildBillingPriceText
(
'缓存读取价格:{{symbol}}{{price}} / 1M tokens'
,
{
symbol
,
usdAmount
:
tier
.
cacheReadPrice
,
rate
})
buildBillingPriceText
(
`
${
label
}
:{{symbol}}{{price}} / 1M tokens`
,
{
symbol
,
usdAmount
:
tier
[
field
],
rate
}),
:
null
,
hasSplitCacheCreation
&&
cacheCreationTokens5m
>
0
&&
tier
.
cacheCreatePrice
>
0
?
buildBillingPriceText
(
'5m缓存创建价格:{{symbol}}{{price}} / 1M tokens'
,
{
symbol
,
usdAmount
:
tier
.
cacheCreatePrice
,
rate
})
:
null
,
hasSplitCacheCreation
&&
cacheCreationTokens1h
>
0
&&
tier
.
cacheCreate1hPrice
>
0
?
buildBillingPriceText
(
'1h缓存创建价格:{{symbol}}{{price}} / 1M tokens'
,
{
symbol
,
usdAmount
:
tier
.
cacheCreate1hPrice
,
rate
})
:
null
,
!
hasSplitCacheCreation
&&
cacheCreationTokens
>
0
&&
tier
.
cacheCreatePrice
>
0
?
buildBillingPriceText
(
'缓存创建价格:{{symbol}}{{price}} / 1M tokens'
,
{
symbol
,
usdAmount
:
tier
.
cacheCreatePrice
,
rate
})
:
null
,
buildBillingText
(
'(输入 {{input}} tokens / 1M tokens * {{symbol}}{{inputPrice}} + 输出 {{output}} tokens / 1M tokens * {{symbol}}{{outputPrice}}) * 分组倍率 {{ratio}} = {{symbol}}{{total}}'
,
{
input
:
inputTokens
,
output
:
completionTokens
,
symbol
,
inputPrice
:
formatBillingDisplayPrice
(
tier
.
inputPrice
,
rate
),
outputPrice
:
formatBillingDisplayPrice
(
tier
.
outputPrice
,
rate
),
ratio
:
gr
,
total
:
formatBillingDisplayPrice
(
total
,
rate
),
},
),
),
];
];
...
@@ -2329,44 +2331,26 @@ export function renderTieredModelPriceSimple(opts) {
...
@@ -2329,44 +2331,26 @@ export function renderTieredModelPriceSimple(opts) {
];
];
if
(
tier
&&
isPriceDisplayMode
(
displayMode
))
{
if
(
tier
&&
isPriceDisplayMode
(
displayMode
))
{
const
priceSegments
=
[
[
'inputPrice'
,
'输入'
],
[
'outputPrice'
,
'补全'
],
[
'cacheReadPrice'
,
'缓存读'
],
[
'cacheCreatePrice'
,
'缓存创建'
],
[
'cacheCreate1hPrice'
,
'1h缓存创建'
],
[
'imagePrice'
,
'图片输入'
],
[
'audioInputPrice'
,
'音频输入'
],
[
'audioOutputPrice'
,
'音频输出'
],
];
for
(
const
[
field
,
label
]
of
priceSegments
)
{
if
(
tier
[
field
]
>
0
)
{
segments
.
push
({
segments
.
push
({
tone
:
'secondary'
,
tone
:
'secondary'
,
text
:
i18next
.
t
(
'输入 {{price}} / 1M tokens'
,
{
text
:
i18next
.
t
(
'{{label}} {{price}} / 1M tokens'
,
{
price
:
formatCompactDisplayPrice
(
tier
.
inputPrice
),
label
:
i18next
.
t
(
label
),
}),
price
:
formatCompactDisplayPrice
(
tier
[
field
]),
});
if
(
cacheTokens
>
0
&&
tier
.
cacheReadPrice
>
0
)
{
segments
.
push
({
tone
:
'secondary'
,
text
:
i18next
.
t
(
'缓存读 {{price}} / 1M tokens'
,
{
price
:
formatCompactDisplayPrice
(
tier
.
cacheReadPrice
),
}),
});
}
const
hasSplitCacheCreation
=
cacheCreationTokens5m
>
0
||
cacheCreationTokens1h
>
0
;
if
(
hasSplitCacheCreation
&&
cacheCreationTokens5m
>
0
&&
tier
.
cacheCreatePrice
>
0
)
{
segments
.
push
({
tone
:
'secondary'
,
text
:
i18next
.
t
(
'5m缓存创建 {{price}} / 1M tokens'
,
{
price
:
formatCompactDisplayPrice
(
tier
.
cacheCreatePrice
),
}),
});
}
if
(
hasSplitCacheCreation
&&
cacheCreationTokens1h
>
0
&&
tier
.
cacheCreate1hPrice
>
0
)
{
segments
.
push
({
tone
:
'secondary'
,
text
:
i18next
.
t
(
'1h缓存创建 {{price}} / 1M tokens'
,
{
price
:
formatCompactDisplayPrice
(
tier
.
cacheCreate1hPrice
),
}),
}),
});
});
}
}
if
(
!
hasSplitCacheCreation
&&
cacheCreationTokens
>
0
&&
tier
.
cacheCreatePrice
>
0
)
{
segments
.
push
({
tone
:
'secondary'
,
text
:
i18next
.
t
(
'缓存创建 {{price}} / 1M tokens'
,
{
price
:
formatCompactDisplayPrice
(
tier
.
cacheCreatePrice
),
}),
});
}
}
}
}
...
...
web/src/helpers/utils.jsx
View file @
5b03b39d
...
@@ -904,9 +904,24 @@ export const formatDynamicPriceSummary = (billingExpr, t, groupRatio = 1) => {
...
@@ -904,9 +904,24 @@ export const formatDynamicPriceSummary = (billingExpr, t, groupRatio = 1) => {
const
tierMatches
=
billingExpr
.
match
(
/tier
\(
/g
)
||
[];
const
tierMatches
=
billingExpr
.
match
(
/tier
\(
/g
)
||
[];
const
tierCount
=
tierMatches
.
length
;
const
tierCount
=
tierMatches
.
length
;
const
firstTierMatch
=
billingExpr
.
match
(
const
varCoeffs
=
{};
/tier
\(
"
[^
"
]
*",
\s
*p
\s
*
\*\s
*
([\d
.eE+-
]
+
)\s
*
\+\s
*c
\s
*
\*\s
*
([\d
.eE+-
]
+
)(?:\s
*
\+\s
*cr
\s
*
\*\s
*
([\d
.eE+-
]
+
))?(?:\s
*
\+\s
*cc
\s
*
\*\s
*
([\d
.eE+-
]
+
))?
/
,
const
varRe
=
/
\b(
p|c|cr|cc|cc1h|img|ai|ao
)\s
*
\*\s
*
([\d
.eE+-
]
+
)
/g
;
);
let
vm
;
while
((
vm
=
varRe
.
exec
(
billingExpr
))
!==
null
)
{
if
(
!
(
vm
[
1
]
in
varCoeffs
))
varCoeffs
[
vm
[
1
]]
=
Number
(
vm
[
2
]);
}
const
hasCoeffs
=
'p'
in
varCoeffs
||
'c'
in
varCoeffs
;
const
varLabels
=
[
[
'p'
,
'输入价格'
],
[
'c'
,
'补全价格'
],
[
'cr'
,
'缓存读取价格'
],
[
'cc'
,
'缓存创建价格'
],
[
'cc1h'
,
'1h缓存创建价格'
],
[
'img'
,
'图片输入价格'
],
[
'ai'
,
'音频输入价格'
],
[
'ao'
,
'音频输出价格'
],
];
const
hasTimeCondition
=
/
\b(?:
hour|weekday|month|day
)\(
/
.
test
(
billingExpr
);
const
hasTimeCondition
=
/
\b(?:
hour|weekday|month|day
)\(
/
.
test
(
billingExpr
);
const
hasRequestCondition
=
/
\b(?:
param|header
)\(
/
.
test
(
billingExpr
);
const
hasRequestCondition
=
/
\b(?:
param|header
)\(
/
.
test
(
billingExpr
);
...
@@ -921,26 +936,18 @@ export const formatDynamicPriceSummary = (billingExpr, t, groupRatio = 1) => {
...
@@ -921,26 +936,18 @@ export const formatDynamicPriceSummary = (billingExpr, t, groupRatio = 1) => {
return
(
return
(
<>
<>
{
firstTierMatch
&&
(
{
hasCoeffs
&&
(
<>
<>
<
span
style=
{
lineStyle
}
>
{
varLabels
.
map
(([
key
,
label
])
=>
{
t
(
'输入价格'
)
}
$
{
(
Number
(
firstTierMatch
[
1
])
*
gr
).
toFixed
(
4
)
}{
unitSuffix
}
key
in
varCoeffs
?
(
</
span
>
<
span
key=
{
key
}
style=
{
lineStyle
}
>
<
span
style=
{
lineStyle
}
>
{
t
(
label
)
}
$
{
(
varCoeffs
[
key
]
*
gr
).
toFixed
(
4
)
}{
unitSuffix
}
{
t
(
'输出价格'
)
}
$
{
(
Number
(
firstTierMatch
[
2
])
*
gr
).
toFixed
(
4
)
}{
unitSuffix
}
</
span
>
{
firstTierMatch
[
3
]
&&
(
<
span
style=
{
lineStyle
}
>
{
t
(
'缓存读取价格'
)
}
$
{
(
Number
(
firstTierMatch
[
3
])
*
gr
).
toFixed
(
4
)
}{
unitSuffix
}
</
span
>
)
}
{
firstTierMatch
[
4
]
&&
(
<
span
style=
{
lineStyle
}
>
{
t
(
'缓存创建价格'
)
}
$
{
(
Number
(
firstTierMatch
[
4
])
*
gr
).
toFixed
(
4
)
}{
unitSuffix
}
</
span
>
</
span
>
)
:
null
,
)
}
)
}
</>
</>
)
}
)
}
{
(
tierCount
>
1
||
hasTimeCondition
||
hasRequestCondition
)
&&
(
<
span
style=
{
{
display
:
'flex'
,
gap
:
4
,
flexWrap
:
'wrap'
}
}
>
<
span
style=
{
{
display
:
'flex'
,
gap
:
4
,
flexWrap
:
'wrap'
}
}
>
<
span
<
span
style=
{
{
style=
{
{
...
@@ -970,6 +977,7 @@ export const formatDynamicPriceSummary = (billingExpr, t, groupRatio = 1) => {
...
@@ -970,6 +977,7 @@ export const formatDynamicPriceSummary = (billingExpr, t, groupRatio = 1) => {
</
span
>
</
span
>
))
}
))
}
</
span
>
</
span
>
)
}
</>
</>
);
);
};
};
...
...
web/src/pages/Setting/Ratio/components/TieredPricingEditor.jsx
View file @
5b03b39d
...
@@ -764,7 +764,7 @@ const PRESET_GROUPS = [
...
@@ -764,7 +764,7 @@ const PRESET_GROUPS = [
presets
:
[
presets
:
[
{
key
:
'flat'
,
label
:
'Flat'
,
expr
:
'tier("base", p * 2 + c * 4)'
},
{
key
:
'flat'
,
label
:
'Flat'
,
expr
:
'tier("base", p * 2 + c * 4)'
},
{
key
:
'claude-opus'
,
label
:
'Claude Opus 4.6'
,
expr
:
'tier("base", p * 5 + c * 25 + cr * 0.5 + cc * 6.25 + cc1h * 10)'
},
{
key
:
'claude-opus'
,
label
:
'Claude Opus 4.6'
,
expr
:
'tier("base", p * 5 + c * 25 + cr * 0.5 + cc * 6.25 + cc1h * 10)'
},
{
key
:
'gpt-5.4'
,
label
:
'GPT-5.4'
,
expr
:
'tier("base", p * 2.5 + c * 1
0
+ cr * 0.25)'
},
{
key
:
'gpt-5.4'
,
label
:
'GPT-5.4'
,
expr
:
'tier("base", p * 2.5 + c * 1
5
+ cr * 0.25)'
},
],
],
},
},
{
{
...
@@ -778,6 +778,7 @@ const PRESET_GROUPS = [
...
@@ -778,6 +778,7 @@ const PRESET_GROUPS = [
{
{
group
:
'多模态'
,
group
:
'多模态'
,
presets
:
[
presets
:
[
{
key
:
'gpt-image-1-mini'
,
label
:
'GPT-Image-1-Mini'
,
expr
:
'tier("base", p * 2 + c * 8 + img * 2.5)'
},
{
key
:
'qwen3-omni-flash'
,
label
:
'Qwen3-Omni-Flash'
,
expr
:
'tier("base", p * 0.43 + c * 3.06 + img * 0.78 + ai * 3.81 + ao * 15.11)'
},
{
key
:
'qwen3-omni-flash'
,
label
:
'Qwen3-Omni-Flash'
,
expr
:
'tier("base", p * 0.43 + c * 3.06 + img * 0.78 + ai * 3.81 + ao * 15.11)'
},
],
],
},
},
...
@@ -791,7 +792,7 @@ const PRESET_GROUPS = [
...
@@ -791,7 +792,7 @@ const PRESET_GROUPS = [
},
},
{
{
key
:
'gpt-5.4-fast'
,
label
:
'GPT-5.4 Fast'
,
key
:
'gpt-5.4-fast'
,
label
:
'GPT-5.4 Fast'
,
expr
:
'tier("base", p * 2.5 + c * 1
0
+ cr * 0.25)'
,
expr
:
'tier("base", p * 2.5 + c * 1
5
+ cr * 0.25)'
,
requestRules
:
[{
conditions
:
[{
source
:
SOURCE_PARAM
,
path
:
'service_tier'
,
mode
:
MATCH_EQ
,
value
:
'fast'
}],
multiplier
:
'2'
}],
requestRules
:
[{
conditions
:
[{
source
:
SOURCE_PARAM
,
path
:
'service_tier'
,
mode
:
MATCH_EQ
,
value
:
'fast'
}],
multiplier
:
'2'
}],
},
},
],
],
...
...
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