Procházet zdrojové kódy

feat: enhance tiered billing logic and improve variable handling in pricing calculations

CaIon před 4 týdny
rodič
revize
5b03b39db2

+ 1 - 1
.gitignore

@@ -29,6 +29,6 @@ data/
 .gomodcache/
 .gocache-temp
 .gopath
-
+.test
 token_estimator_test.go
 skills-lock.json

+ 53 - 6
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()
 }

+ 6 - 11
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

+ 10 - 15
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) {
 		}
 	})
 }
-

+ 57 - 0
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

+ 182 - 0
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)
+	}
+}

+ 21 - 99
web/src/components/table/model-pricing/modal/components/DynamicPricingBreakdown.jsx

@@ -20,6 +20,7 @@ For commercial licensing, please contact [email protected]
 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 }) {
         </div>
       ),
     },
-    {
-      title: `${t('输入价格')} (${PRICE_SUFFIX})`,
-      dataIndex: 'inputPrice',
-      render: (v) => <Text strong>${v.toFixed(4)}</Text>,
-    },
-    {
-      title: `${t('输出价格')} (${PRICE_SUFFIX})`,
-      dataIndex: 'outputPrice',
-      render: (v) => <Text strong>${v.toFixed(4)}</Text>,
-    },
+    ...priceFields
+      .filter(([field]) => hasTiers && tiers.some((tier) => tier[field] > 0))
+      .map(([field, label]) => ({
+        title: `${t(label)} (${PRICE_SUFFIX})`,
+        dataIndex: field,
+        render: (v) => v > 0 ? <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
     ? 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])),
       }))
     : [];
 

+ 71 - 87
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]),
+            }),
+          });
+        }
       }
     }
 

+ 27 - 19
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 && (
         <>
-          <span style={lineStyle}>
-            {t('输入价格')} ${(Number(firstTierMatch[1]) * gr).toFixed(4)}{unitSuffix}
-          </span>
-          <span style={lineStyle}>
-            {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>
+          {varLabels.map(([key, label]) =>
+            key in varCoeffs ? (
+              <span key={key} style={lineStyle}>
+                {t(label)} ${(varCoeffs[key] * gr).toFixed(4)}{unitSuffix}
+              </span>
+            ) : null,
           )}
         </>
       )}
+      {(tierCount > 1 || hasTimeCondition || hasRequestCondition) && (
       <span style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
         <span
           style={{
@@ -970,6 +977,7 @@ export const formatDynamicPriceSummary = (billingExpr, t, groupRatio = 1) => {
           </span>
         ))}
       </span>
+      )}
     </>
   );
 };

+ 3 - 2
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' }],
       },
     ],