From 5b03b39db22c45a30dd6311dada5cd7ed242e41e Mon Sep 17 00:00:00 2001 From: CaIon Date: Mon, 16 Mar 2026 22:00:36 +0800 Subject: [PATCH] feat: enhance tiered billing logic and improve variable handling in pricing calculations --- .gitignore | 2 +- pkg/billingexpr/compile.go | 59 +++++- relay/compatible_handler.go | 17 +- service/quota.go | 25 +-- service/tiered_settle.go | 57 ++++++ service/tiered_settle_test.go | 182 ++++++++++++++++++ .../components/DynamicPricingBreakdown.jsx | 120 ++---------- web/src/helpers/render.jsx | 158 +++++++-------- web/src/helpers/utils.jsx | 46 +++-- .../Ratio/components/TieredPricingEditor.jsx | 5 +- 10 files changed, 431 insertions(+), 240 deletions(-) diff --git a/.gitignore b/.gitignore index b5d65bcf..d65fdedd 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,6 @@ data/ .gomodcache/ .gocache-temp .gopath - +.test token_estimator_test.go skills-lock.json \ No newline at end of file diff --git a/pkg/billingexpr/compile.go b/pkg/billingexpr/compile.go index edb77217..e73dc337 100644 --- a/pkg/billingexpr/compile.go +++ b/pkg/billingexpr/compile.go @@ -6,14 +6,20 @@ import ( "sync" "github.com/expr-lang/expr" + "github.com/expr-lang/expr/ast" "github.com/expr-lang/expr/vm" ) const maxCacheSize = 256 +type cachedEntry struct { + prog *vm.Program + usedVars map[string]bool +} + var ( 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. @@ -67,9 +73,9 @@ func CompileFromCacheByHash(exprStr, hash string) (*vm.Program, error) { func compileFromCacheByHash(exprStr, hash string) (*vm.Program, error) { cacheMu.RLock() - if prog, ok := cache[hash]; ok { + if entry, ok := cache[hash]; ok { cacheMu.RUnlock() - return prog, nil + return entry.prog, nil } cacheMu.RUnlock() @@ -78,20 +84,61 @@ func compileFromCacheByHash(exprStr, hash string) (*vm.Program, error) { return nil, fmt.Errorf("expr compile error: %w", err) } + vars := extractUsedVars(prog) + cacheMu.Lock() 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() 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. // Called when billing rules are updated. func InvalidateCache() { cacheMu.Lock() - cache = make(map[string]*vm.Program, 64) + cache = make(map[string]*cachedEntry, 64) cacheMu.Unlock() } diff --git a/relay/compatible_handler.go b/relay/compatible_handler.go index 63a9d2ee..d1379af1 100644 --- a/relay/compatible_handler.go +++ b/relay/compatible_handler.go @@ -238,17 +238,13 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage } // 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 - tieredOk, tieredQuota, tieredRes := service.TryTieredSettle(relayInfo, billingexpr.TokenParams{ - 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), - }) + tieredOk, tieredQuota, tieredRes := service.TryTieredSettle(relayInfo, service.BuildTieredTokenParams(usage, isClaudeUsageSemantic, tieredUsedVars)) if tieredOk { tieredResult = tieredRes } @@ -354,7 +350,6 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage var audioInputQuota decimal.Decimal var audioInputPrice float64 - isClaudeUsageSemantic := relayInfo.GetFinalRequestRelayFormat() == types.RelayFormatClaude if !relayInfo.PriceData.UsePrice { baseTokens := dPromptTokens // 减去 cached tokens diff --git a/service/quota.go b/service/quota.go index 4341898c..95d3cd54 100644 --- a/service/quota.go +++ b/service/quota.go @@ -256,14 +256,12 @@ func PostClaudeConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, 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 - tieredOk, tieredQuota, tieredRes := TryTieredSettle(relayInfo, billingexpr.TokenParams{ - P: float64(usage.PromptTokens), - C: float64(usage.CompletionTokens), - CR: float64(usage.PromptTokensDetails.CachedTokens), - CC: float64(usage.PromptTokensDetails.CachedCreationTokens - usage.ClaudeCacheCreation1hTokens), - CC1h: float64(usage.ClaudeCacheCreation1hTokens), - }) + tieredOk, tieredQuota, tieredRes := TryTieredSettle(relayInfo, BuildTieredTokenParams(usage, true, tieredUsedVars)) if tieredOk { 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) { + var tieredUsedVars map[string]bool + if snap := relayInfo.TieredBillingSnapshot; snap != nil { + tieredUsedVars = billingexpr.UsedVars(snap.ExprString) + } var tieredResult *billingexpr.TieredResult - tieredOk, tieredQuota, tieredRes := TryTieredSettle(relayInfo, billingexpr.TokenParams{ - P: float64(usage.PromptTokens), - C: float64(usage.CompletionTokens), - CR: float64(usage.PromptTokensDetails.CachedTokens), - AI: float64(usage.PromptTokensDetails.AudioTokens), - AO: float64(usage.CompletionTokenDetails.AudioTokens), - }) + tieredOk, tieredQuota, tieredRes := TryTieredSettle(relayInfo, BuildTieredTokenParams(usage, false, tieredUsedVars)) if tieredOk { tieredResult = tieredRes } @@ -659,4 +655,3 @@ func checkAndSendSubscriptionQuotaNotify(relayInfo *relaycommon.RelayInfo) { } }) } - diff --git a/service/tiered_settle.go b/service/tiered_settle.go index 83f5b6f6..2c67d0e4 100644 --- a/service/tiered_settle.go +++ b/service/tiered_settle.go @@ -1,6 +1,7 @@ package service import ( + "github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/pkg/billingexpr" relaycommon "github.com/QuantumNous/new-api/relay/common" ) @@ -8,6 +9,62 @@ import ( // TieredResultWrapper wraps billingexpr.TieredResult for use at the service layer. 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, // computes the actual quota using the frozen BillingSnapshot. Returns: // - ok=true, quota, result when tiered billing applies diff --git a/service/tiered_settle_test.go b/service/tiered_settle_test.go index c67e89ef..7d31e160 100644 --- a/service/tiered_settle_test.go +++ b/service/tiered_settle_test.go @@ -1,8 +1,10 @@ package service import ( + "math" "testing" + "github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/pkg/billingexpr" 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") } } + +// --------------------------------------------------------------------------- +// 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) + } +} diff --git a/web/src/components/table/model-pricing/modal/components/DynamicPricingBreakdown.jsx b/web/src/components/table/model-pricing/modal/components/DynamicPricingBreakdown.jsx index abbee423..564fe88f 100644 --- a/web/src/components/table/model-pricing/modal/components/DynamicPricingBreakdown.jsx +++ b/web/src/components/table/model-pricing/modal/components/DynamicPricingBreakdown.jsx @@ -20,6 +20,7 @@ For commercial licensing, please contact support@quantumnous.com import React from 'react'; import { Card, Avatar, Tag, Table, Typography } from '@douyinfe/semi-ui'; import { IconPriceTag } from '@douyinfe/semi-icons'; +import { parseTiersFromExpr } from '../../../../../helpers'; import { splitBillingExprAndRequestRules, tryParseRequestRuleExpr, @@ -36,15 +37,6 @@ const { Text } = Typography; 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 OP_LABELS = { '<': '<', '<=': '≤', '>': '>', '>=': '≥' }; const TIME_FUNC_LABELS = { hour: '小时', minute: '分钟', weekday: '星期', month: '月份', day: '日期' }; @@ -71,54 +63,6 @@ function formatConditionSummary(conditions, t) { .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) { if (cond.source === SOURCE_TIME) { @@ -147,7 +91,7 @@ export default function DynamicPricingBreakdown({ billingExpr, t }) { const { billingExpr: baseExpr, requestRuleExpr: ruleExpr } = splitBillingExprAndRequestRules(billingExpr || ''); - const tiers = tryParseTiers(baseExpr); + const tiers = parseTiersFromExpr(baseExpr); const ruleGroups = tryParseRequestRuleExpr(ruleExpr || ''); 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 = [ { title: t('档位'), @@ -182,54 +137,21 @@ export default function DynamicPricingBreakdown({ billingExpr, t }) { ), }, - { - title: `${t('输入价格')} (${PRICE_SUFFIX})`, - dataIndex: 'inputPrice', - render: (v) => ${v.toFixed(4)}, - }, - { - title: `${t('输出价格')} (${PRICE_SUFFIX})`, - dataIndex: 'outputPrice', - render: (v) => ${v.toFixed(4)}, - }, + ...priceFields + .filter(([field]) => hasTiers && tiers.some((tier) => tier[field] > 0)) + .map(([field, label]) => ({ + title: `${t(label)} (${PRICE_SUFFIX})`, + dataIndex: field, + render: (v) => v > 0 ? ${v.toFixed(4)} : '-', + })), ]; - 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 ? ${v.toFixed(4)} : '-', - }); - } - if (hasCacheCreate) { - tierColumns.push({ - title: `${t('缓存创建')} (${PRICE_SUFFIX})`, - dataIndex: 'cacheCreatePrice', - render: (v) => v != null ? ${v.toFixed(4)} : '-', - }); - } - if (hasCache1h) { - tierColumns.push({ - title: `${t('缓存创建-1h')} (${PRICE_SUFFIX})`, - dataIndex: 'cacheCreate1hPrice', - render: (v) => v != null ? ${v.toFixed(4)} : '-', - }); - } - const tierData = hasTiers ? tiers.map((tier, i) => ({ key: `tier-${i}`, label: tier.label, condSummary: formatConditionSummary(tier.conditions, t), - inputPrice: tier.inputPrice, - outputPrice: tier.outputPrice, - cacheReadPrice: tier.cacheReadPrice, - cacheCreatePrice: tier.cacheCreatePrice, - cacheCreate1hPrice: tier.cacheCreate1hPrice, + ...Object.fromEntries(priceFields.map(([field]) => [field, tier[field] || 0])), })) : []; diff --git a/web/src/helpers/render.jsx b/web/src/helpers/render.jsx index 4500026f..e18eb9f2 100644 --- a/web/src/helpers/render.jsx +++ b/web/src/helpers/render.jsx @@ -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 []; 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 tierRe = new RegExp(`tier\\("([^"]*)",\\s*${bodyPat}\\)`, 'g'); + 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*([^)]+)\\)`, 'g'); const tiers = []; let m; while ((m = tierRe.exec(exprStr)) !== null) { - tiers.push({ - label: m[1], - inputPrice: Number(m[2]), - outputPrice: Number(m[3]), - cacheReadPrice: m[4] ? Number(m[4]) : 0, - cacheCreatePrice: m[5] ? Number(m[5]) : 0, - cacheCreate1hPrice: m[6] ? Number(m[6]) : 0, - }); + const condStr = m[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]) }); + } + } + const tier = parseTierBody(m[3]); + tier.label = m[2]; + tier.conditions = conditions; + tiers.push(tier); } return tiers; } catch { @@ -2258,45 +2281,24 @@ export function renderTieredModelPrice(opts) { const { symbol, rate } = getCurrencyConfig(); const gr = groupRatio || 1; - const inputCost = (inputTokens / 1000000) * tier.inputPrice; - const outputCost = (completionTokens / 1000000) * tier.outputPrice; - const cacheReadCost = (cacheTokens / 1000000) * tier.cacheReadPrice; - const hasSplitCacheCreation = cacheCreationTokens5m > 0 || cacheCreationTokens1h > 0; - let cacheCreateCost = 0; - if (hasSplitCacheCreation) { - cacheCreateCost = (cacheCreationTokens5m / 1000000) * tier.cacheCreatePrice - + (cacheCreationTokens1h / 1000000) * tier.cacheCreate1hPrice; - } else if (cacheCreationTokens > 0) { - cacheCreateCost = (cacheCreationTokens / 1000000) * tier.cacheCreatePrice; - } - const totalBeforeGroup = inputCost + outputCost + cacheReadCost + cacheCreateCost; - const total = totalBeforeGroup * gr; + const priceLines = [ + ['inputPrice', '输入价格'], + ['outputPrice', '补全价格'], + ['cacheReadPrice', '缓存读取价格'], + ['cacheCreatePrice', '缓存创建价格'], + ['cacheCreate1hPrice', '1h缓存创建价格'], + ['imagePrice', '图片输入价格'], + ['audioInputPrice', '音频输入价格'], + ['audioOutputPrice', '音频输出价格'], + ]; const lines = [ buildBillingText('命中档位:{{tier}}', { tier: matchedTier || tier.label }), - buildBillingPriceText('输入价格:{{symbol}}{{price}} / 1M tokens', { symbol, usdAmount: tier.inputPrice, rate }), - buildBillingPriceText('输出价格:{{symbol}}{{price}} / 1M tokens', { symbol, usdAmount: tier.outputPrice, rate }), - cacheTokens > 0 && tier.cacheReadPrice > 0 - ? buildBillingPriceText('缓存读取价格:{{symbol}}{{price}} / 1M tokens', { symbol, usdAmount: tier.cacheReadPrice, 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), - }, - ), + ...priceLines + .filter(([field]) => tier[field] > 0) + .map(([field, label]) => + buildBillingPriceText(`${label}:{{symbol}}{{price}} / 1M tokens`, { symbol, usdAmount: tier[field], rate }), + ), ]; return renderBillingArticle(lines); @@ -2329,44 +2331,26 @@ export function renderTieredModelPriceSimple(opts) { ]; if (tier && isPriceDisplayMode(displayMode)) { - segments.push({ - tone: 'secondary', - text: i18next.t('输入 {{price}} / 1M tokens', { - price: formatCompactDisplayPrice(tier.inputPrice), - }), - }); - 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), - }), - }); + const priceSegments = [ + ['inputPrice', '输入'], + ['outputPrice', '补全'], + ['cacheReadPrice', '缓存读'], + ['cacheCreatePrice', '缓存创建'], + ['cacheCreate1hPrice', '1h缓存创建'], + ['imagePrice', '图片输入'], + ['audioInputPrice', '音频输入'], + ['audioOutputPrice', '音频输出'], + ]; + for (const [field, label] of priceSegments) { + if (tier[field] > 0) { + segments.push({ + tone: 'secondary', + text: i18next.t('{{label}} {{price}} / 1M tokens', { + label: i18next.t(label), + price: formatCompactDisplayPrice(tier[field]), + }), + }); + } } } diff --git a/web/src/helpers/utils.jsx b/web/src/helpers/utils.jsx index dc1c1efc..d21e549d 100644 --- a/web/src/helpers/utils.jsx +++ b/web/src/helpers/utils.jsx @@ -904,9 +904,24 @@ export const formatDynamicPriceSummary = (billingExpr, t, groupRatio = 1) => { const tierMatches = billingExpr.match(/tier\(/g) || []; const tierCount = tierMatches.length; - const firstTierMatch = billingExpr.match( - /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 varCoeffs = {}; + 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 hasRequestCondition = /\b(?:param|header)\(/.test(billingExpr); @@ -921,26 +936,18 @@ export const formatDynamicPriceSummary = (billingExpr, t, groupRatio = 1) => { return ( <> - {firstTierMatch && ( + {hasCoeffs && ( <> - - {t('输入价格')} ${(Number(firstTierMatch[1]) * gr).toFixed(4)}{unitSuffix} - - - {t('输出价格')} ${(Number(firstTierMatch[2]) * gr).toFixed(4)}{unitSuffix} - - {firstTierMatch[3] && ( - - {t('缓存读取价格')} ${(Number(firstTierMatch[3]) * gr).toFixed(4)}{unitSuffix} - - )} - {firstTierMatch[4] && ( - - {t('缓存创建价格')} ${(Number(firstTierMatch[4]) * gr).toFixed(4)}{unitSuffix} - + {varLabels.map(([key, label]) => + key in varCoeffs ? ( + + {t(label)} ${(varCoeffs[key] * gr).toFixed(4)}{unitSuffix} + + ) : null, )} )} + {(tierCount > 1 || hasTimeCondition || hasRequestCondition) && ( { ))} + )} ); }; diff --git a/web/src/pages/Setting/Ratio/components/TieredPricingEditor.jsx b/web/src/pages/Setting/Ratio/components/TieredPricingEditor.jsx index 8e1cd32b..3fc103e7 100644 --- a/web/src/pages/Setting/Ratio/components/TieredPricingEditor.jsx +++ b/web/src/pages/Setting/Ratio/components/TieredPricingEditor.jsx @@ -764,7 +764,7 @@ const PRESET_GROUPS = [ presets: [ { 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: '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: '多模态', 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)' }, ], }, @@ -791,7 +792,7 @@ const PRESET_GROUPS = [ }, { 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' }], }, ],