feat: enhance tiered billing logic and improve variable handling in pricing calculations
This commit is contained in:
parent
f6c0852da9
commit
5b03b39db2
2
.gitignore
vendored
2
.gitignore
vendored
@ -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
|
||||||
@ -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) {
|
|||||||
|
|
||||||
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) {
|
|||||||
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()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
|||||||
|
|
||||||
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
|
||||||
|
|||||||
@ -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)
|
|||||||
|
|
||||||
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) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
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 (
|
|||||||
// 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
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
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) {
|
|||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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;
|
|||||||
|
|
||||||
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) {
|
|||||||
.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 }) {
|
|||||||
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 }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 }) {
|
|||||||
</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,
|
|
||||||
}))
|
}))
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
|||||||
158
web/src/helpers/render.jsx
vendored
158
web/src/helpers/render.jsx
vendored
@ -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,45 +2281,24 @@ 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),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return renderBillingArticle(lines);
|
return renderBillingArticle(lines);
|
||||||
@ -2329,44 +2331,26 @@ export function renderTieredModelPriceSimple(opts) {
|
|||||||
];
|
];
|
||||||
|
|
||||||
if (tier && isPriceDisplayMode(displayMode)) {
|
if (tier && isPriceDisplayMode(displayMode)) {
|
||||||
segments.push({
|
const priceSegments = [
|
||||||
tone: 'secondary',
|
['inputPrice', '输入'],
|
||||||
text: i18next.t('输入 {{price}} / 1M tokens', {
|
['outputPrice', '补全'],
|
||||||
price: formatCompactDisplayPrice(tier.inputPrice),
|
['cacheReadPrice', '缓存读'],
|
||||||
}),
|
['cacheCreatePrice', '缓存创建'],
|
||||||
});
|
['cacheCreate1hPrice', '1h缓存创建'],
|
||||||
if (cacheTokens > 0 && tier.cacheReadPrice > 0) {
|
['imagePrice', '图片输入'],
|
||||||
segments.push({
|
['audioInputPrice', '音频输入'],
|
||||||
tone: 'secondary',
|
['audioOutputPrice', '音频输出'],
|
||||||
text: i18next.t('缓存读 {{price}} / 1M tokens', {
|
];
|
||||||
price: formatCompactDisplayPrice(tier.cacheReadPrice),
|
for (const [field, label] of priceSegments) {
|
||||||
}),
|
if (tier[field] > 0) {
|
||||||
});
|
segments.push({
|
||||||
}
|
tone: 'secondary',
|
||||||
const hasSplitCacheCreation = cacheCreationTokens5m > 0 || cacheCreationTokens1h > 0;
|
text: i18next.t('{{label}} {{price}} / 1M tokens', {
|
||||||
if (hasSplitCacheCreation && cacheCreationTokens5m > 0 && tier.cacheCreatePrice > 0) {
|
label: i18next.t(label),
|
||||||
segments.push({
|
price: formatCompactDisplayPrice(tier[field]),
|
||||||
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),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
46
web/src/helpers/utils.jsx
vendored
46
web/src/helpers/utils.jsx
vendored
@ -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) => {
|
|||||||
|
|
||||||
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>
|
||||||
</span>
|
) : null,
|
||||||
{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>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{(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) => {
|
|||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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 * 10 + cr * 0.25)' },
|
{ key: 'gpt-5.4', label: 'GPT-5.4', expr: 'tier("base", p * 2.5 + c * 15 + cr * 0.25)' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -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 = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
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 * 10 + cr * 0.25)',
|
expr: 'tier("base", p * 2.5 + c * 15 + 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' }],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user