Просмотр исходного кода

feat: enhance tiered billing functionality and UI components

- Introduced new fields for billing mode and expression in the Pricing model.
- Implemented dynamic pricing breakdown component to display tiered billing details.
- Updated various components to support and render tiered billing information.
- Enhanced pricing calculation logic to accommodate dynamic pricing scenarios.
- Added tests for new billing expression functionalities and UI components.
CaIon 1 месяц назад
Родитель
Сommit
f0589cc478

+ 9 - 0
model/pricing.go

@@ -10,6 +10,7 @@ import (
 
 	"github.com/QuantumNous/new-api/common"
 	"github.com/QuantumNous/new-api/constant"
+	"github.com/QuantumNous/new-api/setting/billing_setting"
 	"github.com/QuantumNous/new-api/setting/ratio_setting"
 	"github.com/QuantumNous/new-api/types"
 )
@@ -32,6 +33,8 @@ type Pricing struct {
 	AudioCompletionRatio   *float64                `json:"audio_completion_ratio,omitempty"`
 	EnableGroup            []string                `json:"enable_groups"`
 	SupportedEndpointTypes []constant.EndpointType `json:"supported_endpoint_types"`
+	BillingMode            string                  `json:"billing_mode,omitempty"`
+	BillingExpr            string                  `json:"billing_expr,omitempty"`
 	PricingVersion         string                  `json:"pricing_version,omitempty"`
 }
 
@@ -319,6 +322,12 @@ func updatePricing() {
 			audioCompletionRatio := ratio_setting.GetAudioCompletionRatio(model)
 			pricing.AudioCompletionRatio = &audioCompletionRatio
 		}
+		if billingMode := billing_setting.GetBillingMode(model); billingMode == "tiered_expr" {
+			pricing.BillingMode = billingMode
+			if expr, ok := billing_setting.GetBillingExpr(model); ok {
+				pricing.BillingExpr = expr
+			}
+		}
 		pricingMap = append(pricingMap, pricing)
 	}
 

+ 52 - 0
pkg/billingexpr/billingexpr_test.go

@@ -931,3 +931,55 @@ func TestTimeFunctions_MonthDayPattern(t *testing.T) {
 		t.Errorf("cost = %f, want 1000 or 500", cost)
 	}
 }
+
+// ---------------------------------------------------------------------------
+// Image and audio token tests
+// ---------------------------------------------------------------------------
+
+func TestImageTokenVariable(t *testing.T) {
+	exprStr := `tier("base", p * 2 + c * 10 + img * 5)`
+	cost, _, err := billingexpr.RunExpr(exprStr, billingexpr.TokenParams{P: 1000, C: 500, Img: 200})
+	if err != nil {
+		t.Fatal(err)
+	}
+	// 1000*2 + 500*10 + 200*5 = 2000 + 5000 + 1000 = 8000
+	if math.Abs(cost-8000) > 1e-6 {
+		t.Errorf("cost = %f, want 8000", cost)
+	}
+}
+
+func TestAudioTokenVariables(t *testing.T) {
+	exprStr := `tier("base", p * 2 + c * 10 + ai * 50 + ao * 100)`
+	cost, _, err := billingexpr.RunExpr(exprStr, billingexpr.TokenParams{P: 1000, C: 500, AI: 100, AO: 50})
+	if err != nil {
+		t.Fatal(err)
+	}
+	// 1000*2 + 500*10 + 100*50 + 50*100 = 2000 + 5000 + 5000 + 5000 = 17000
+	if math.Abs(cost-17000) > 1e-6 {
+		t.Errorf("cost = %f, want 17000", cost)
+	}
+}
+
+func TestImageAudioAliases(t *testing.T) {
+	exprStr := `tier("base", prompt_tokens * 1 + image_tokens * 3 + audio_input_tokens * 5 + audio_output_tokens * 10)`
+	cost, _, err := billingexpr.RunExpr(exprStr, billingexpr.TokenParams{P: 100, Img: 50, AI: 20, AO: 10})
+	if err != nil {
+		t.Fatal(err)
+	}
+	// 100*1 + 50*3 + 20*5 + 10*10 = 100 + 150 + 100 + 100 = 450
+	if math.Abs(cost-450) > 1e-6 {
+		t.Errorf("cost = %f, want 450", cost)
+	}
+}
+
+func TestImageAudioZero(t *testing.T) {
+	exprStr := `tier("base", p * 2 + img * 5 + ai * 50 + ao * 100)`
+	cost, _, err := billingexpr.RunExpr(exprStr, billingexpr.TokenParams{P: 1000})
+	if err != nil {
+		t.Fatal(err)
+	}
+	// img, ai, ao default to 0
+	if math.Abs(cost-2000) > 1e-6 {
+		t.Errorf("cost = %f, want 2000", cost)
+	}
+}

+ 6 - 0
pkg/billingexpr/compile.go

@@ -31,6 +31,12 @@ var compileEnvPrototype = map[string]interface{}{
 	"cache_read_tokens":      float64(0),
 	"cache_create_tokens":    float64(0),
 	"cache_create_1h_tokens": float64(0),
+	"img":                    float64(0),
+	"ai":                     float64(0),
+	"ao":                     float64(0),
+	"image_tokens":           float64(0),
+	"audio_input_tokens":     float64(0),
+	"audio_output_tokens":    float64(0),
 	"tier":                   func(string, float64) float64 { return 0 },
 	"header":                 func(string) string { return "" },
 	"param":                  func(string) interface{} { return nil },

+ 6 - 0
pkg/billingexpr/run.go

@@ -62,6 +62,12 @@ func runProgram(prog *vm.Program, params TokenParams, request RequestInput) (flo
 		"cache_read_tokens":      params.CR,
 		"cache_create_tokens":    params.CC,
 		"cache_create_1h_tokens": params.CC1h,
+		"img":                    params.Img,
+		"ai":                     params.AI,
+		"ao":                     params.AO,
+		"image_tokens":           params.Img,
+		"audio_input_tokens":     params.AI,
+		"audio_output_tokens":    params.AO,
 		"tier": func(name string, value float64) float64 {
 			trace.MatchedTier = name
 			trace.Cost = value

+ 5 - 2
pkg/billingexpr/types.go

@@ -14,11 +14,14 @@ type RequestInput struct {
 // Fields beyond P and C are optional — when absent they default to 0,
 // which means cache-unaware expressions keep working unchanged.
 type TokenParams struct {
-	P    float64 // prompt tokens
-	C    float64 // completion tokens
+	P    float64 // prompt tokens (text)
+	C    float64 // completion tokens (text)
 	CR   float64 // cache read (hit) tokens
 	CC   float64 // cache creation tokens (5-min TTL for Claude, generic for others)
 	CC1h float64 // cache creation tokens — 1-hour TTL (Claude only)
+	Img  float64 // image input tokens
+	AI   float64 // audio input tokens
+	AO   float64 // audio output tokens
 }
 
 // TraceResult holds side-channel info captured by the tier() function

+ 15 - 55
relay/compatible_handler.go

@@ -237,16 +237,20 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
 		service.ObserveChannelAffinityUsageCacheByRelayFormat(ctx, usage, relayInfo.GetFinalRequestRelayFormat())
 	}
 
-	// Tiered billing early return
-	if ok, tieredQuota, tieredResult := service.TryTieredSettle(relayInfo, billingexpr.TokenParams{
+	// Tiered billing: only determines quota, logging continues through normal path
+	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),
-	}); ok {
-		postConsumeQuotaTiered(ctx, relayInfo, usage, tieredQuota, tieredResult, extraContent...)
-		return
+		Img:  float64(usage.PromptTokensDetails.ImageTokens),
+		AI:   float64(usage.PromptTokensDetails.AudioTokens),
+		AO:   float64(usage.CompletionTokenDetails.AudioTokens),
+	})
+	if tieredOk {
+		tieredResult = tieredRes
 	}
 
 	adminRejectReason := common.GetContextKeyString(ctx, constant.ContextKeyAdminRejectReason)
@@ -419,10 +423,11 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
 	}
 
 	quota := int(quotaCalculateDecimal.Round(0).IntPart())
+	if tieredOk {
+		quota = tieredQuota
+	}
 	totalTokens := promptTokens + completionTokens
 
-	//var logContent string
-
 	// record all the consume log even if quota is 0
 	if totalTokens == 0 {
 		// in this case, must be some error happened
@@ -504,6 +509,9 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
 		other["image_generation_call"] = true
 		other["image_generation_call_price"] = imageGenerationCallPrice
 	}
+	if tieredResult != nil {
+		service.InjectTieredBillingInfo(other, relayInfo, tieredResult)
+	}
 	model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{
 		ChannelId:        relayInfo.ChannelId,
 		PromptTokens:     promptTokens,
@@ -519,51 +527,3 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
 		Other:            other,
 	})
 }
-
-func postConsumeQuotaTiered(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, quota int, tieredResult *service.TieredResultWrapper, extraContent ...string) {
-	_ = tieredResult // will be used for log enrichment
-
-	useTimeSeconds := time.Now().Unix() - relayInfo.StartTime.Unix()
-	modelName := relayInfo.OriginModelName
-	tokenName := ctx.GetString("token_name")
-	groupRatio := relayInfo.PriceData.GroupRatioInfo.GroupRatio
-
-	totalTokens := usage.PromptTokens + usage.CompletionTokens
-
-	if totalTokens == 0 {
-		quota = 0
-		extraContent = append(extraContent, "上游没有返回计费信息,无法扣费(可能是上游超时)")
-		logger.LogError(ctx, fmt.Sprintf("tiered billing: total tokens is 0, userId %d, channelId %d, tokenId %d, model %s, pre-consumed %d",
-			relayInfo.UserId, relayInfo.ChannelId, relayInfo.TokenId, modelName, relayInfo.FinalPreConsumedQuota))
-	} else {
-		if groupRatio != 0 && quota == 0 {
-			quota = 1
-		}
-		model.UpdateUserUsedQuotaAndRequestCount(relayInfo.UserId, quota)
-		model.UpdateChannelUsedQuota(relayInfo.ChannelId, quota)
-	}
-
-	if err := service.SettleBilling(ctx, relayInfo, quota); err != nil {
-		logger.LogError(ctx, "error settling tiered billing: "+err.Error())
-	}
-
-	logModel := modelName
-	logContent := strings.Join(extraContent, ", ")
-
-	other := service.GenerateTieredOtherInfo(ctx, relayInfo, tieredResult)
-
-	model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{
-		ChannelId:        relayInfo.ChannelId,
-		PromptTokens:     usage.PromptTokens,
-		CompletionTokens: usage.CompletionTokens,
-		ModelName:        logModel,
-		TokenName:        tokenName,
-		Quota:            quota,
-		Content:          logContent,
-		TokenId:          relayInfo.TokenId,
-		UseTimeSeconds:   int(useTimeSeconds),
-		IsStream:         relayInfo.IsStream,
-		Group:            relayInfo.UsingGroup,
-		Other:            other,
-	})
-}

+ 9 - 32
service/log_info_generate.go

@@ -1,6 +1,7 @@
 package service
 
 import (
+	"encoding/base64"
 	"strings"
 
 	"github.com/QuantumNous/new-api/common"
@@ -216,41 +217,17 @@ func GenerateMjOtherInfo(relayInfo *relaycommon.RelayInfo, priceData types.Price
 	return other
 }
 
-func GenerateTieredOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, result *billingexpr.TieredResult) map[string]interface{} {
-	other := make(map[string]interface{})
-	other["billing_mode"] = "tiered_expr"
-
+// InjectTieredBillingInfo overlays tiered billing fields onto an existing
+// module-specific other map. Call this after GenerateTextOtherInfo /
+// GenerateClaudeOtherInfo / etc. when the request used tiered_expr billing.
+func InjectTieredBillingInfo(other map[string]interface{}, relayInfo *relaycommon.RelayInfo, result *billingexpr.TieredResult) {
 	snap := relayInfo.TieredBillingSnapshot
-	if snap != nil {
-		other["group_ratio"] = snap.GroupRatio
-		other["expr_hash"] = snap.ExprHash
-		other["estimated_prompt_tokens"] = snap.EstimatedPromptTokens
-		other["estimated_completion_tokens"] = snap.EstimatedCompletionTokens
-		other["estimated_quota_before_group"] = snap.EstimatedQuotaBeforeGroup
-		other["estimated_quota_after_group"] = snap.EstimatedQuotaAfterGroup
-		other["estimated_tier"] = snap.EstimatedTier
+	if snap == nil {
+		return
 	}
-
+	other["billing_mode"] = "tiered_expr"
+	other["expr_b64"] = base64.StdEncoding.EncodeToString([]byte(snap.ExprString))
 	if result != nil {
-		other["actual_quota_before_group"] = result.ActualQuotaBeforeGroup
-		other["actual_quota_after_group"] = result.ActualQuotaAfterGroup
 		other["matched_tier"] = result.MatchedTier
-		other["crossed_tier"] = result.CrossedTier
-	}
-
-	other["frt"] = float64(relayInfo.FirstResponseTime.UnixMilli() - relayInfo.StartTime.UnixMilli())
-	if relayInfo.IsModelMapped {
-		other["is_model_mapped"] = true
-		other["upstream_model_name"] = relayInfo.UpstreamModelName
 	}
-
-	adminInfo := make(map[string]interface{})
-	adminInfo["use_channel"] = ctx.GetStringSlice("use_channel")
-	AppendChannelAffinityAdminInfo(ctx, adminInfo)
-	other["admin_info"] = adminInfo
-
-	appendRequestPath(ctx, relayInfo, other)
-	appendRequestConversionChain(relayInfo, other)
-	appendBillingInfo(relayInfo, other)
-	return other
 }

+ 35 - 61
service/quota.go

@@ -158,13 +158,13 @@ func PreWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usag
 func PostWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, modelName string,
 	usage *dto.RealtimeUsage, extraContent string) {
 
-	// Tiered billing early return
-	if ok, tieredQuota, tieredResult := TryTieredSettle(relayInfo, billingexpr.TokenParams{
+	var tieredResult *billingexpr.TieredResult
+	tieredOk, tieredQuota, tieredRes := TryTieredSettle(relayInfo, billingexpr.TokenParams{
 		P: float64(usage.InputTokens),
 		C: float64(usage.OutputTokens),
-	}); ok {
-		postConsumeQuotaTieredService(ctx, relayInfo, modelName, usage.InputTokens, usage.OutputTokens, usage.TotalTokens, tieredQuota, tieredResult, extraContent)
-		return
+	})
+	if tieredOk {
+		tieredResult = tieredRes
 	}
 
 	useTimeSeconds := time.Now().Unix() - relayInfo.StartTime.Unix()
@@ -200,6 +200,9 @@ func PostWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, mod
 	}
 
 	quota := calculateAudioQuota(quotaInfo)
+	if tieredOk {
+		quota = tieredQuota
+	}
 
 	totalTokens := usage.TotalTokens
 	var logContent string
@@ -229,6 +232,9 @@ func PostWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, mod
 	}
 	other := GenerateWssOtherInfo(ctx, relayInfo, usage, modelRatio, groupRatio,
 		completionRatio.InexactFloat64(), audioRatio.InexactFloat64(), audioCompletionRatio.InexactFloat64(), modelPrice, relayInfo.PriceData.GroupRatioInfo.GroupSpecialRatio)
+	if tieredResult != nil {
+		InjectTieredBillingInfo(other, relayInfo, tieredResult)
+	}
 	model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{
 		ChannelId:        relayInfo.ChannelId,
 		PromptTokens:     usage.InputTokens,
@@ -250,16 +256,16 @@ func PostClaudeConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
 		ObserveChannelAffinityUsageCacheByRelayFormat(ctx, usage, relayInfo.GetFinalRequestRelayFormat())
 	}
 
-	// Tiered billing early return
-	if ok, tieredQuota, tieredResult := TryTieredSettle(relayInfo, billingexpr.TokenParams{
+	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),
-	}); ok {
-		postConsumeQuotaTieredService(ctx, relayInfo, relayInfo.OriginModelName, usage.PromptTokens, usage.CompletionTokens, usage.PromptTokens+usage.CompletionTokens, tieredQuota, tieredResult, "")
-		return
+	})
+	if tieredOk {
+		tieredResult = tieredRes
 	}
 
 	useTimeSeconds := time.Now().Unix() - relayInfo.StartTime.Unix()
@@ -315,6 +321,9 @@ func PostClaudeConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
 	}
 
 	quota := int(calculateQuota)
+	if tieredOk {
+		quota = tieredQuota
+	}
 
 	totalTokens := promptTokens + completionTokens
 
@@ -342,6 +351,9 @@ func PostClaudeConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
 		cacheCreationTokens5m, cacheCreationRatio5m,
 		cacheCreationTokens1h, cacheCreationRatio1h,
 		modelPrice, relayInfo.PriceData.GroupRatioInfo.GroupSpecialRatio)
+	if tieredResult != nil {
+		InjectTieredBillingInfo(other, relayInfo, tieredResult)
+	}
 	model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{
 		ChannelId:        relayInfo.ChannelId,
 		PromptTokens:     promptTokens,
@@ -382,14 +394,16 @@ func CalcOpenRouterCacheCreateTokens(usage dto.Usage, priceData types.PriceData)
 
 func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, extraContent string) {
 
-	// Tiered billing early return
-	if ok, tieredQuota, tieredResult := TryTieredSettle(relayInfo, billingexpr.TokenParams{
+	var tieredResult *billingexpr.TieredResult
+	tieredOk, tieredQuota, tieredRes := TryTieredSettle(relayInfo, billingexpr.TokenParams{
 		P:  float64(usage.PromptTokens),
 		C:  float64(usage.CompletionTokens),
 		CR: float64(usage.PromptTokensDetails.CachedTokens),
-	}); ok {
-		postConsumeQuotaTieredService(ctx, relayInfo, relayInfo.OriginModelName, usage.PromptTokens, usage.CompletionTokens, usage.TotalTokens, tieredQuota, tieredResult, extraContent)
-		return
+		AI: float64(usage.PromptTokensDetails.AudioTokens),
+		AO: float64(usage.CompletionTokenDetails.AudioTokens),
+	})
+	if tieredOk {
+		tieredResult = tieredRes
 	}
 
 	useTimeSeconds := time.Now().Unix() - relayInfo.StartTime.Unix()
@@ -425,6 +439,9 @@ func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, u
 	}
 
 	quota := calculateAudioQuota(quotaInfo)
+	if tieredOk {
+		quota = tieredQuota
+	}
 
 	totalTokens := usage.TotalTokens
 	var logContent string
@@ -458,6 +475,9 @@ func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, u
 	}
 	other := GenerateAudioOtherInfo(ctx, relayInfo, usage, modelRatio, groupRatio,
 		completionRatio.InexactFloat64(), audioRatio.InexactFloat64(), audioCompletionRatio.InexactFloat64(), modelPrice, relayInfo.PriceData.GroupRatioInfo.GroupSpecialRatio)
+	if tieredResult != nil {
+		InjectTieredBillingInfo(other, relayInfo, tieredResult)
+	}
 	model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{
 		ChannelId:        relayInfo.ChannelId,
 		PromptTokens:     usage.PromptTokens,
@@ -640,49 +660,3 @@ func checkAndSendSubscriptionQuotaNotify(relayInfo *relaycommon.RelayInfo) {
 	})
 }
 
-func postConsumeQuotaTieredService(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, modelName string,
-	promptTokens, completionTokens, totalTokens, quota int, tieredResult *TieredResultWrapper, extraContent string) {
-
-	useTimeSeconds := time.Now().Unix() - relayInfo.StartTime.Unix()
-	tokenName := ctx.GetString("token_name")
-	groupRatio := relayInfo.PriceData.GroupRatioInfo.GroupRatio
-
-	var logContent string
-	if totalTokens == 0 {
-		quota = 0
-		logContent = "上游没有返回计费信息(可能是上游超时)"
-		logger.LogError(ctx, fmt.Sprintf("tiered billing: total tokens is 0, userId %d, channelId %d, tokenId %d, model %s, pre-consumed %d",
-			relayInfo.UserId, relayInfo.ChannelId, relayInfo.TokenId, modelName, relayInfo.FinalPreConsumedQuota))
-	} else {
-		if groupRatio != 0 && quota == 0 {
-			quota = 1
-		}
-		model.UpdateUserUsedQuotaAndRequestCount(relayInfo.UserId, quota)
-		model.UpdateChannelUsedQuota(relayInfo.ChannelId, quota)
-	}
-
-	if err := SettleBilling(ctx, relayInfo, quota); err != nil {
-		logger.LogError(ctx, "error settling tiered billing: "+err.Error())
-	}
-
-	if extraContent != "" {
-		logContent += extraContent
-	}
-
-	other := GenerateTieredOtherInfo(ctx, relayInfo, tieredResult)
-
-	model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{
-		ChannelId:        relayInfo.ChannelId,
-		PromptTokens:     promptTokens,
-		CompletionTokens: completionTokens,
-		ModelName:        modelName,
-		TokenName:        tokenName,
-		Quota:            quota,
-		Content:          logContent,
-		TokenId:          relayInfo.TokenId,
-		UseTimeSeconds:   int(useTimeSeconds),
-		IsStream:         relayInfo.IsStream,
-		Group:            relayInfo.UsingGroup,
-		Other:            other,
-	})
-}

+ 7 - 0
web/src/components/table/model-pricing/modal/ModelDetailSideSheet.jsx

@@ -26,6 +26,7 @@ import ModelHeader from './components/ModelHeader';
 import ModelBasicInfo from './components/ModelBasicInfo';
 import ModelEndpoints from './components/ModelEndpoints';
 import ModelPricingTable from './components/ModelPricingTable';
+import DynamicPricingBreakdown from './components/DynamicPricingBreakdown';
 
 const { Text } = Typography;
 
@@ -89,6 +90,12 @@ const ModelDetailSideSheet = ({
               endpointMap={endpointMap}
               t={t}
             />
+            {modelData.billing_mode === 'tiered_expr' && modelData.billing_expr && (
+              <DynamicPricingBreakdown
+                billingExpr={modelData.billing_expr}
+                t={t}
+              />
+            )}
             <ModelPricingTable
               modelData={modelData}
               groupRatio={groupRatio}

+ 293 - 0
web/src/components/table/model-pricing/modal/components/DynamicPricingBreakdown.jsx

@@ -0,0 +1,293 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+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 {
+  splitBillingExprAndRequestRules,
+  tryParseRequestRuleExpr,
+  SOURCE_TIME,
+  MATCH_RANGE,
+  MATCH_EQ,
+  MATCH_GTE,
+  MATCH_LT,
+  MATCH_CONTAINS,
+  MATCH_EXISTS,
+} from '../../../../../pages/Setting/Ratio/components/requestRuleExpr';
+
+const { Text } = Typography;
+
+const PRICE_SUFFIX = '$/1M tokens';
+
+function unitCostToPrice(uc) {
+  return (Number(uc) || 0) * 2;
+}
+
+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: '日期' };
+
+function formatTokenHint(value) {
+  const n = Number(value);
+  if (!Number.isFinite(n) || n === 0) return '';
+  if (n >= 1000000) return `${(n / 1000000).toFixed(n % 1000000 === 0 ? 0 : 1)}M`;
+  if (n >= 1000) return `${(n / 1000).toFixed(n % 1000 === 0 ? 0 : 1)}K`;
+  return String(n);
+}
+
+function formatConditionSummary(conditions, t) {
+  return conditions
+    .map((c) => {
+      if (c.var && c.op) {
+        const varLabel = t(VAR_LABELS[c.var] || c.var);
+        const hint = formatTokenHint(c.value);
+        return `${varLabel} ${OP_LABELS[c.op] || c.op} ${hint || c.value}`;
+      }
+      return '';
+    })
+    .filter(Boolean)
+    .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) {
+    const fn = t(TIME_FUNC_LABELS[cond.timeFunc] || cond.timeFunc);
+    const tz = cond.timezone || 'UTC';
+    if (cond.mode === MATCH_RANGE) {
+      return `${fn} ${cond.rangeStart}:00~${cond.rangeEnd}:00 (${tz})`;
+    }
+    const opMap = { [MATCH_EQ]: '=', [MATCH_GTE]: '≥', [MATCH_LT]: '<' };
+    return `${fn} ${opMap[cond.mode] || '='} ${cond.value} (${tz})`;
+  }
+  const src = cond.source === 'header' ? t('请求头') : t('请求参数');
+  const path = cond.path || '';
+  if (cond.mode === MATCH_EXISTS) return `${src} ${path} ${t('存在')}`;
+  if (cond.mode === MATCH_CONTAINS) return `${src} ${path} ${t('包含')} "${cond.value}"`;
+  const opMap = { eq: '=', gt: '>', gte: '≥', lt: '<', lte: '≤' };
+  return `${src} ${path} ${opMap[cond.mode] || '='} ${cond.value}`;
+}
+
+function describeGroup(group, t) {
+  const parts = (group.conditions || []).map((c) => describeCondition(c, t));
+  return parts.join(' && ');
+}
+
+export default function DynamicPricingBreakdown({ billingExpr, t }) {
+  const { billingExpr: baseExpr, requestRuleExpr: ruleExpr } =
+    splitBillingExprAndRequestRules(billingExpr || '');
+
+  const tiers = tryParseTiers(baseExpr);
+  const ruleGroups = tryParseRequestRuleExpr(ruleExpr || '');
+
+  const hasTiers = tiers && tiers.length > 0;
+  const hasRules = ruleGroups && ruleGroups.length > 0;
+
+  if (!hasTiers && !hasRules) {
+    return (
+      <Card className='!rounded-2xl shadow-sm border-0'>
+        <div className='flex items-center mb-3'>
+          <Avatar size='small' color='amber' className='mr-2 shadow-md'>
+            <IconPriceTag size={16} />
+          </Avatar>
+          <Text className='text-lg font-medium'>{t('动态计费')}</Text>
+        </div>
+        <div className='text-sm text-gray-500'>
+          <code style={{ fontSize: 12, wordBreak: 'break-all' }}>{billingExpr}</code>
+        </div>
+      </Card>
+    );
+  }
+
+  const tierColumns = [
+    {
+      title: t('档位'),
+      dataIndex: 'label',
+      render: (text, record) => (
+        <div>
+          <Tag color='blue' size='small'>{text || t('默认')}</Tag>
+          {record.condSummary && (
+            <div className='text-xs text-gray-500 mt-1'>{record.condSummary}</div>
+          )}
+        </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>,
+    },
+  ];
+
+  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,
+      }))
+    : [];
+
+  return (
+    <Card className='!rounded-2xl shadow-sm border-0'>
+      <div className='flex items-center mb-4'>
+        <Avatar size='small' color='amber' className='mr-2 shadow-md'>
+          <IconPriceTag size={16} />
+        </Avatar>
+        <div>
+          <Text className='text-lg font-medium'>{t('动态计费')}</Text>
+          <div className='text-xs text-gray-600'>
+            {t('价格根据用量档位和请求条件动态调整')}
+          </div>
+        </div>
+      </div>
+
+      {hasTiers && (
+        <div style={{ marginBottom: 16 }}>
+          <Text strong className='text-sm' style={{ display: 'block', marginBottom: 8 }}>
+            {t('分档价格表')}
+          </Text>
+          <Table
+            dataSource={tierData}
+            columns={tierColumns}
+            pagination={false}
+            size='small'
+            bordered={false}
+            className='!rounded-lg'
+          />
+        </div>
+      )}
+
+      {hasRules && (
+        <div style={{ marginBottom: 16 }}>
+          <Text strong className='text-sm' style={{ display: 'block', marginBottom: 8 }}>
+            {t('条件乘数')}
+          </Text>
+          {ruleGroups.map((group, gi) => (
+            <div
+              key={`group-${gi}`}
+              style={{
+                display: 'flex',
+                justifyContent: 'space-between',
+                alignItems: 'center',
+                padding: '8px 12px',
+                borderRadius: 6,
+                background: 'var(--semi-color-fill-0)',
+                marginBottom: 4,
+              }}
+            >
+              <Text size='small'>{describeGroup(group, t)}</Text>
+              <Tag color='orange' size='small'>{group.multiplier}x</Tag>
+            </div>
+          ))}
+        </div>
+      )}
+
+    </Card>
+  );
+}

+ 34 - 21
web/src/components/table/model-pricing/modal/components/ModelPricingTable.jsx

@@ -71,11 +71,13 @@ const ModelPricingTable = ({
         group: group,
         ratio: groupRatioValue,
         billingType:
-          modelData?.quota_type === 0
-            ? t('按量计费')
-            : modelData?.quota_type === 1
-              ? t('按次计费')
-              : '-',
+          modelData?.billing_mode === 'tiered_expr'
+            ? t('动态计费')
+            : modelData?.quota_type === 0
+              ? t('按量计费')
+              : modelData?.quota_type === 1
+                ? t('按次计费')
+                : '-',
         priceItems: getModelPriceItems(priceData, t, siteDisplayType),
       };
     });
@@ -94,20 +96,21 @@ const ModelPricingTable = ({
       },
     ];
 
-    // 如果显示倍率,添加倍率列
-    if (showRatio) {
+    const isDynamic = modelData?.billing_mode === 'tiered_expr';
+
+    // 动态计费时始终显示倍率列,否则根据设置
+    if (showRatio || isDynamic) {
       columns.push({
-        title: t('倍率'),
+        title: t('分组倍率'),
         dataIndex: 'ratio',
         render: (text) => (
-          <Tag color='white' size='small' shape='circle'>
+          <Tag color='blue' size='small' shape='circle'>
             {text}x
           </Tag>
         ),
       });
     }
 
-    // 添加计费类型列
     columns.push({
       title: t('计费类型'),
       dataIndex: 'billingType',
@@ -115,6 +118,7 @@ const ModelPricingTable = ({
         let color = 'white';
         if (text === t('按量计费')) color = 'violet';
         else if (text === t('按次计费')) color = 'teal';
+        else if (text === t('动态计费')) color = 'amber';
         return (
           <Tag color={color} size='small' shape='circle'>
             {text || '-'}
@@ -126,18 +130,27 @@ const ModelPricingTable = ({
     columns.push({
       title: siteDisplayType === 'TOKENS' ? t('计费摘要') : t('价格摘要'),
       dataIndex: 'priceItems',
-      render: (items) => (
-        <div className='space-y-1'>
-          {items.map((item) => (
-            <div key={item.key}>
-              <div className='font-semibold text-orange-600'>
-                {item.label} {item.value}
+      render: (items) => {
+        if (items.length === 1 && items[0].isDynamic) {
+          return (
+            <Text type='tertiary' size='small'>
+              {t('见上方动态计费详情')}
+            </Text>
+          );
+        }
+        return (
+          <div className='space-y-1'>
+            {items.map((item) => (
+              <div key={item.key}>
+                <div className='font-semibold text-orange-600'>
+                  {item.label} {item.value}
+                </div>
+                <div className='text-xs text-gray-500'>{item.suffix}</div>
               </div>
-              <div className='text-xs text-gray-500'>{item.suffix}</div>
-            </div>
-          ))}
-        </div>
-      ),
+            ))}
+          </div>
+        );
+      },
     });
 
     return (

+ 6 - 1
web/src/components/table/model-pricing/view/card/PricingCardView.jsx

@@ -38,6 +38,7 @@ import {
   stringToColor,
   calculateModelPrice,
   formatPriceInfo,
+  formatDynamicPriceSummary,
   getLobeHubIcon,
 } from '../../../../../helpers';
 import PricingCardSkeleton from './PricingCardSkeleton';
@@ -267,7 +268,11 @@ const PricingCardView = ({
                         {model.model_name}
                       </h3>
                       <div className='flex flex-col gap-1 text-xs mt-1'>
-                        {formatPriceInfo(priceData, t, siteDisplayType)}
+                        {priceData.isDynamicPricing ? (
+                          formatDynamicPriceSummary(priceData.billingExpr, t)
+                        ) : (
+                          formatPriceInfo(priceData, t, siteDisplayType)
+                        )}
                       </div>
                     </div>
                   </div>

+ 6 - 78
web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx

@@ -33,6 +33,7 @@ import {
   getLogOther,
   renderModelTag,
   renderModelPriceSimple,
+  renderTieredModelPriceSimple,
 } from '../../../helpers';
 import { IconHelpCircle } from '@douyinfe/semi-icons';
 import { Route, Sparkles } from 'lucide-react';
@@ -377,43 +378,6 @@ function renderCompactDetailSummary(summarySegments) {
   );
 }
 
-function buildTieredBillingSegments(other, t) {
-  const segments = [
-    { text: `${t('阶梯计费')}`, tone: 'primary' },
-  ];
-
-  if (other.matched_tier) {
-    segments.push({
-      text: `${t('命中档位')}: ${other.matched_tier}`,
-      tone: 'secondary',
-    });
-  }
-
-  const groupRatio = other.group_ratio;
-  if (groupRatio !== undefined && groupRatio !== null) {
-    segments.push({
-      text: `${t('分组')} ${formatRatio(groupRatio)}x`,
-      tone: 'secondary',
-    });
-  }
-
-  if (other.crossed_tier) {
-    segments.push({
-      text: `${t('跨阶梯')}: ${t('是')}`,
-      tone: 'secondary',
-    });
-  }
-
-  if (other.actual_quota_after_group !== undefined) {
-    segments.push({
-      text: `${t('实际额度')}: ${other.actual_quota_after_group}`,
-      tone: 'secondary',
-    });
-  }
-
-  return { segments };
-}
-
 function getUsageLogDetailSummary(record, text, billingDisplayMode, t) {
   const other = getLogOther(record.other);
 
@@ -451,52 +415,16 @@ function getUsageLogDetailSummary(record, text, billingDisplayMode, t) {
     };
   }
 
+  const summaryOpts = { ...other, displayMode: billingDisplayMode, outputMode: 'segments' };
+
   if (other?.billing_mode === 'tiered_expr') {
-    return buildTieredBillingSegments(other, t);
+    return { segments: renderTieredModelPriceSimple(summaryOpts) };
   }
 
   return {
     segments: other?.claude
-      ? renderModelPriceSimple(
-          other.model_ratio,
-          other.model_price,
-          other.group_ratio,
-          other?.user_group_ratio,
-          other.cache_tokens || 0,
-          other.cache_ratio || 1.0,
-          other.cache_creation_tokens || 0,
-          other.cache_creation_ratio || 1.0,
-          other.cache_creation_tokens_5m || 0,
-          other.cache_creation_ratio_5m || other.cache_creation_ratio || 1.0,
-          other.cache_creation_tokens_1h || 0,
-          other.cache_creation_ratio_1h || other.cache_creation_ratio || 1.0,
-          false,
-          1.0,
-          other?.is_system_prompt_overwritten,
-          'claude',
-          billingDisplayMode,
-          'segments',
-        )
-      : renderModelPriceSimple(
-          other.model_ratio,
-          other.model_price,
-          other.group_ratio,
-          other?.user_group_ratio,
-          other.cache_tokens || 0,
-          other.cache_ratio || 1.0,
-          0,
-          1.0,
-          0,
-          1.0,
-          0,
-          1.0,
-          false,
-          1.0,
-          other?.is_system_prompt_overwritten,
-          'openai',
-          billingDisplayMode,
-          'segments',
-        ),
+      ? renderModelPriceSimple({ ...summaryOpts, provider: 'claude' })
+      : renderModelPriceSimple({ ...summaryOpts, provider: 'openai' }),
   };
 }
 

+ 289 - 117
web/src/helpers/render.jsx

@@ -1620,37 +1620,38 @@ function renderPriceSimpleCore({
   return result;
 }
 
-export function renderModelPrice(
-  inputTokens,
-  completionTokens,
-  modelRatio,
-  modelPrice = -1,
-  completionRatio,
-  groupRatio,
-  user_group_ratio,
-  cacheTokens = 0,
-  cacheRatio = 1.0,
-  image = false,
-  imageRatio = 1.0,
-  imageOutputTokens = 0,
-  webSearch = false,
-  webSearchCallCount = 0,
-  webSearchPrice = 0,
-  fileSearch = false,
-  fileSearchCallCount = 0,
-  fileSearchPrice = 0,
-  audioInputSeperatePrice = false,
-  audioInputTokens = 0,
-  audioInputPrice = 0,
-  imageGenerationCall = false,
-  imageGenerationCallPrice = 0,
-  displayMode = 'price',
-) {
+export function renderModelPrice(opts) {
+  const {
+    prompt_tokens: inputTokens = 0,
+    completion_tokens: completionTokens = 0,
+    model_ratio: modelRatio = 0,
+    model_price: modelPrice = -1,
+    completion_ratio: completionRatio,
+    group_ratio: _groupRatio,
+    user_group_ratio,
+    cache_tokens: cacheTokens = 0,
+    cache_ratio: cacheRatio = 1.0,
+    image = false,
+    image_ratio: imageRatio = 1.0,
+    image_output: imageOutputTokens = 0,
+    web_search: webSearch = false,
+    web_search_call_count: webSearchCallCount = 0,
+    web_search_price: webSearchPrice = 0,
+    file_search: fileSearch = false,
+    file_search_call_count: fileSearchCallCount = 0,
+    file_search_price: fileSearchPrice = 0,
+    audio_input_seperate_price: audioInputSeperatePrice = false,
+    audio_input_token_count: audioInputTokens = 0,
+    audio_input_price: audioInputPrice = 0,
+    image_generation_call: imageGenerationCall = false,
+    image_generation_call_price: imageGenerationCallPrice = 0,
+    displayMode = 'price',
+  } = opts;
   const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(
-    groupRatio,
+    _groupRatio,
     user_group_ratio,
   );
-  groupRatio = effectiveGroupRatio;
+  let groupRatio = effectiveGroupRatio;
 
   const { symbol, rate } = getCurrencyConfig();
 
@@ -2078,21 +2079,22 @@ export function renderModelPrice(
   ]);
 }
 
-export function renderLogContent(
-  modelRatio,
-  completionRatio,
-  modelPrice = -1,
-  groupRatio,
-  user_group_ratio,
-  cacheRatio = 1.0,
-  image = false,
-  imageRatio = 1.0,
-  webSearch = false,
-  webSearchCallCount = 0,
-  fileSearch = false,
-  fileSearchCallCount = 0,
-  displayMode = 'price',
-) {
+export function renderLogContent(opts) {
+  const {
+    model_ratio: modelRatio,
+    completion_ratio: completionRatio,
+    model_price: modelPrice = -1,
+    group_ratio: groupRatio,
+    user_group_ratio,
+    cache_ratio: cacheRatio = 1.0,
+    image = false,
+    image_ratio: imageRatio = 1.0,
+    web_search: webSearch = false,
+    web_search_call_count: webSearchCallCount = 0,
+    file_search: fileSearch = false,
+    file_search_call_count: fileSearchCallCount = 0,
+    displayMode = 'price',
+  } = opts;
   const {
     ratio,
     label: ratioLabel,
@@ -2208,26 +2210,193 @@ export function renderLogContent(
   }
 }
 
-export function renderModelPriceSimple(
-  modelRatio,
-  modelPrice = -1,
-  groupRatio,
-  user_group_ratio,
-  cacheTokens = 0,
-  cacheRatio = 1.0,
-  cacheCreationTokens = 0,
-  cacheCreationRatio = 1.0,
-  cacheCreationTokens5m = 0,
-  cacheCreationRatio5m = 1.0,
-  cacheCreationTokens1h = 0,
-  cacheCreationRatio1h = 1.0,
-  image = false,
-  imageRatio = 1.0,
-  isSystemPromptOverride = false,
-  provider = 'openai',
-  displayMode = 'price',
-  outputMode = 'text',
-) {
+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 tiers = [];
+    let m;
+    while ((m = tierRe.exec(exprStr)) !== null) {
+      tiers.push({
+        label: m[1],
+        inputPrice: Number(m[2]) * 2,
+        outputPrice: Number(m[3]) * 2,
+        cacheReadPrice: m[4] ? Number(m[4]) * 2 : 0,
+        cacheCreatePrice: m[5] ? Number(m[5]) * 2 : 0,
+        cacheCreate1hPrice: m[6] ? Number(m[6]) * 2 : 0,
+      });
+    }
+    return tiers;
+  } catch {
+    return [];
+  }
+}
+
+export function renderTieredModelPrice(opts) {
+  const {
+    prompt_tokens: inputTokens = 0,
+    completion_tokens: completionTokens = 0,
+    expr_b64: exprB64,
+    matched_tier: matchedTier,
+    group_ratio: groupRatio,
+    cache_tokens: cacheTokens = 0,
+    cache_creation_tokens: cacheCreationTokens = 0,
+    cache_creation_tokens_5m: cacheCreationTokens5m = 0,
+    cache_creation_tokens_1h: cacheCreationTokens1h = 0,
+  } = opts;
+  let exprStr = '';
+  try { exprStr = atob(exprB64); } catch { /* ignore */ }
+  const tiers = parseTiersFromExpr(exprStr);
+  if (tiers.length === 0) {
+    return i18next.t('阶梯计费(表达式解析失败)');
+  }
+
+  const tier = tiers.find((t) => t.label === matchedTier) || tiers[0];
+  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 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),
+      },
+    ),
+  ];
+
+  return renderBillingArticle(lines);
+}
+
+export function renderTieredModelPriceSimple(opts) {
+  const {
+    expr_b64: exprB64,
+    matched_tier: matchedTier,
+    group_ratio: groupRatio,
+    user_group_ratio,
+    cache_tokens: cacheTokens = 0,
+    cache_creation_tokens_5m: cacheCreationTokens5m = 0,
+    cache_creation_tokens_1h: cacheCreationTokens1h = 0,
+    cache_creation_tokens: cacheCreationTokens = 0,
+    displayMode = 'price',
+    outputMode = 'segments',
+  } = opts;
+  let exprStr = '';
+  try { exprStr = atob(exprB64); } catch { /* ignore */ }
+  const tiers = parseTiersFromExpr(exprStr);
+  const tier = tiers.find((t) => t.label === matchedTier) || tiers[0];
+
+  if (outputMode === 'segments') {
+    const segments = [
+      {
+        tone: 'primary',
+        text: getGroupRatioText(groupRatio, user_group_ratio),
+      },
+    ];
+
+    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),
+          }),
+        });
+      }
+    }
+
+    return segments;
+  }
+
+  return [];
+}
+
+export function renderModelPriceSimple(opts) {
+  const {
+    model_ratio: modelRatio,
+    model_price: modelPrice = -1,
+    group_ratio: groupRatio,
+    user_group_ratio,
+    cache_tokens: cacheTokens = 0,
+    cache_ratio: cacheRatio = 1.0,
+    cache_creation_tokens: cacheCreationTokens = 0,
+    cache_creation_ratio: cacheCreationRatio = 1.0,
+    cache_creation_tokens_5m: cacheCreationTokens5m = 0,
+    cache_creation_ratio_5m: cacheCreationRatio5m = 1.0,
+    cache_creation_tokens_1h: cacheCreationTokens1h = 0,
+    cache_creation_ratio_1h: cacheCreationRatio1h = 1.0,
+    image = false,
+    image_ratio: imageRatio = 1.0,
+    is_system_prompt_overwritten: isSystemPromptOverride = false,
+    provider = 'openai',
+    displayMode = 'price',
+    outputMode = 'text',
+  } = opts;
   return renderPriceSimpleCore({
     modelRatio,
     modelPrice,
@@ -2249,27 +2418,28 @@ export function renderModelPriceSimple(
   });
 }
 
-export function renderAudioModelPrice(
-  inputTokens,
-  completionTokens,
-  modelRatio,
-  modelPrice = -1,
-  completionRatio,
-  audioInputTokens,
-  audioCompletionTokens,
-  audioRatio,
-  audioCompletionRatio,
-  groupRatio,
-  user_group_ratio,
-  cacheTokens = 0,
-  cacheRatio = 1.0,
-  displayMode = 'price',
-) {
+export function renderAudioModelPrice(opts) {
+  const {
+    prompt_tokens: inputTokens = 0,
+    completion_tokens: completionTokens = 0,
+    model_ratio: modelRatio = 0,
+    model_price: modelPrice = -1,
+    completion_ratio: completionRatio,
+    audio_input: audioInputTokens = 0,
+    audio_output: audioCompletionTokens = 0,
+    audio_ratio: audioRatio,
+    audio_completion_ratio: audioCompletionRatio,
+    group_ratio: _groupRatio,
+    user_group_ratio,
+    cache_tokens: cacheTokens = 0,
+    cache_ratio: cacheRatio = 1.0,
+    displayMode = 'price',
+  } = opts;
   const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(
-    groupRatio,
+    _groupRatio,
     user_group_ratio,
   );
-  groupRatio = effectiveGroupRatio;
+  let groupRatio = effectiveGroupRatio;
 
   // 获取货币配置
   const { symbol, rate } = getCurrencyConfig();
@@ -2535,29 +2705,30 @@ export function renderQuotaWithPrompt(quota, digits) {
   return '';
 }
 
-export function renderClaudeModelPrice(
-  inputTokens,
-  completionTokens,
-  modelRatio,
-  modelPrice = -1,
-  completionRatio,
-  groupRatio,
-  user_group_ratio,
-  cacheTokens = 0,
-  cacheRatio = 1.0,
-  cacheCreationTokens = 0,
-  cacheCreationRatio = 1.0,
-  cacheCreationTokens5m = 0,
-  cacheCreationRatio5m = 1.0,
-  cacheCreationTokens1h = 0,
-  cacheCreationRatio1h = 1.0,
-  displayMode = 'price',
-) {
+export function renderClaudeModelPrice(opts) {
+  const {
+    prompt_tokens: inputTokens = 0,
+    completion_tokens: completionTokens = 0,
+    model_ratio: modelRatio = 0,
+    model_price: modelPrice = -1,
+    completion_ratio: completionRatio,
+    group_ratio: _groupRatio,
+    user_group_ratio,
+    cache_tokens: cacheTokens = 0,
+    cache_ratio: cacheRatio = 1.0,
+    cache_creation_tokens: cacheCreationTokens = 0,
+    cache_creation_ratio: cacheCreationRatio = 1.0,
+    cache_creation_tokens_5m: cacheCreationTokens5m = 0,
+    cache_creation_ratio_5m: cacheCreationRatio5m = 1.0,
+    cache_creation_tokens_1h: cacheCreationTokens1h = 0,
+    cache_creation_ratio_1h: cacheCreationRatio1h = 1.0,
+    displayMode = 'price',
+  } = opts;
   const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(
-    groupRatio,
+    _groupRatio,
     user_group_ratio,
   );
-  groupRatio = effectiveGroupRatio;
+  let groupRatio = effectiveGroupRatio;
 
   // 获取货币配置
   const { symbol, rate } = getCurrencyConfig();
@@ -2944,25 +3115,26 @@ export function renderClaudeModelPrice(
   ]);
 }
 
-export function renderClaudeLogContent(
-  modelRatio,
-  completionRatio,
-  modelPrice = -1,
-  groupRatio,
-  user_group_ratio,
-  cacheRatio = 1.0,
-  cacheCreationRatio = 1.0,
-  cacheCreationTokens5m = 0,
-  cacheCreationRatio5m = 1.0,
-  cacheCreationTokens1h = 0,
-  cacheCreationRatio1h = 1.0,
-  displayMode = 'price',
-) {
+export function renderClaudeLogContent(opts) {
+  const {
+    model_ratio: modelRatio,
+    completion_ratio: completionRatio,
+    model_price: modelPrice = -1,
+    group_ratio: _groupRatio,
+    user_group_ratio,
+    cache_ratio: cacheRatio = 1.0,
+    cache_creation_ratio: cacheCreationRatio = 1.0,
+    cache_creation_tokens_5m: cacheCreationTokens5m = 0,
+    cache_creation_ratio_5m: cacheCreationRatio5m = 1.0,
+    cache_creation_tokens_1h: cacheCreationTokens1h = 0,
+    cache_creation_ratio_1h: cacheCreationRatio1h = 1.0,
+    displayMode = 'price',
+  } = opts;
   const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(
-    groupRatio,
+    _groupRatio,
     user_group_ratio,
   );
-  groupRatio = effectiveGroupRatio;
+  let groupRatio = effectiveGroupRatio;
 
   // 获取货币配置
   const { symbol, rate } = getCurrencyConfig();

+ 100 - 1
web/src/helpers/utils.jsx

@@ -645,7 +645,17 @@ export const calculateModelPrice = ({
     }
   }
 
-  // 2. 根据计费类型计算价格
+  // 2. 动态计费(tiered_expr)
+  if (record.billing_mode === 'tiered_expr' && record.billing_expr) {
+    return {
+      isDynamicPricing: true,
+      billingExpr: record.billing_expr,
+      usedGroup,
+      usedGroupRatio,
+    };
+  }
+
+  // 3. 根据计费类型计算价格
   if (record.quota_type === 0) {
     // 按量计费
     const isTokensDisplay = quotaDisplayType === 'TOKENS';
@@ -766,6 +776,18 @@ export const getModelPriceItems = (
   t,
   quotaDisplayType = 'USD',
 ) => {
+  if (priceData.isDynamicPricing) {
+    return [
+      {
+        key: 'dynamic',
+        label: t('动态计费'),
+        value: '',
+        suffix: '',
+        isDynamic: true,
+      },
+    ];
+  }
+
   if (priceData.isPerToken) {
     if (quotaDisplayType === 'TOKENS' || priceData.isTokensDisplay) {
       return [
@@ -874,6 +896,83 @@ export const getModelPriceItems = (
   ].filter((item) => item.value !== null && item.value !== undefined && item.value !== '');
 };
 
+// 格式化动态计费摘要(用于卡片视图,与 formatPriceInfo 风格统一)
+export const formatDynamicPriceSummary = (billingExpr, t) => {
+  if (!billingExpr) return <span style={{ color: 'var(--semi-color-text-1)' }}>{t('动态计费')}</span>;
+
+  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 hasTimeCondition = /\b(?:hour|weekday|month|day)\(/.test(billingExpr);
+  const hasRequestCondition = /\b(?:param|header)\(/.test(billingExpr);
+
+  const tags = [];
+  if (tierCount > 1) tags.push(`${tierCount}${t('档')}`);
+  if (hasTimeCondition) tags.push(t('含时间条件'));
+  if (hasRequestCondition) tags.push(t('含请求条件'));
+
+  const unitSuffix = ' / 1M Tokens';
+  const lineStyle = { color: 'var(--semi-color-text-1)' };
+
+  return (
+    <>
+      {firstTierMatch && (
+        <>
+          <span style={lineStyle}>
+            {t('输入价格')} ${(Number(firstTierMatch[1]) * 2).toFixed(4)}{unitSuffix}
+          </span>
+          <span style={lineStyle}>
+            {t('输出价格')} ${(Number(firstTierMatch[2]) * 2).toFixed(4)}{unitSuffix}
+          </span>
+          {firstTierMatch[3] && (
+            <span style={lineStyle}>
+              {t('缓存读取价格')} ${(Number(firstTierMatch[3]) * 2).toFixed(4)}{unitSuffix}
+            </span>
+          )}
+          {firstTierMatch[4] && (
+            <span style={lineStyle}>
+              {t('缓存创建价格')} ${(Number(firstTierMatch[4]) * 2).toFixed(4)}{unitSuffix}
+            </span>
+          )}
+        </>
+      )}
+      <span style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
+        <span
+          style={{
+            display: 'inline-block',
+            padding: '1px 6px',
+            borderRadius: 4,
+            fontSize: 11,
+            background: 'var(--semi-color-warning-light-default)',
+            color: 'var(--semi-color-warning)',
+          }}
+        >
+          {t('动态计费')}
+        </span>
+        {tags.map((tag) => (
+          <span
+            key={tag}
+            style={{
+              display: 'inline-block',
+              padding: '1px 6px',
+              borderRadius: 4,
+              fontSize: 11,
+              background: 'var(--semi-color-fill-1)',
+              color: 'var(--semi-color-text-2)',
+            }}
+          >
+            {tag}
+          </span>
+        ))}
+      </span>
+    </>
+  );
+};
+
 // 格式化价格信息(用于卡片视图)
 export const formatPriceInfo = (priceData, t, quotaDisplayType = 'USD') => {
   const items = getModelPriceItems(priceData, t, quotaDisplayType);

+ 26 - 159
web/src/hooks/usage-logs/useUsageLogsData.jsx

@@ -36,6 +36,7 @@ import {
   renderAudioModelPrice,
   renderClaudeModelPrice,
   renderModelPrice,
+  renderTieredModelPrice,
 } from '../../helpers';
 import { ITEMS_PER_PAGE } from '../../constants';
 import { useTableCompactMode } from '../common/useTableCompactMode';
@@ -407,43 +408,14 @@ export const useLogsData = () => {
         });
       }
       if (logs[i].type === 2) {
-        expandDataLocal.push({
-          key: t('日志详情'),
-          value: other?.claude
-            ? renderClaudeLogContent(
-                other?.model_ratio,
-                other.completion_ratio,
-                other.model_price,
-                other.group_ratio,
-                other?.user_group_ratio,
-                other.cache_ratio || 1.0,
-                other.cache_creation_ratio || 1.0,
-                other.cache_creation_tokens_5m || 0,
-                other.cache_creation_ratio_5m ||
-                  other.cache_creation_ratio ||
-                  1.0,
-                other.cache_creation_tokens_1h || 0,
-                other.cache_creation_ratio_1h ||
-                  other.cache_creation_ratio ||
-                  1.0,
-                billingDisplayMode,
-              )
-            : renderLogContent(
-                other?.model_ratio,
-                other.completion_ratio,
-                other.model_price,
-                other.group_ratio,
-                other?.user_group_ratio,
-                other.cache_ratio || 1.0,
-                false,
-                1.0,
-                other.web_search || false,
-                other.web_search_call_count || 0,
-                other.file_search || false,
-                other.file_search_call_count || 0,
-                billingDisplayMode,
-              ),
-        });
+        if (other?.billing_mode !== 'tiered_expr') {
+          expandDataLocal.push({
+            key: t('日志详情'),
+            value: other?.claude
+              ? renderClaudeLogContent({ ...other, displayMode: billingDisplayMode })
+              : renderLogContent({ ...other, displayMode: billingDisplayMode }),
+          });
+        }
         if (logs[i]?.content) {
           expandDataLocal.push({
             key: t('其他详情'),
@@ -479,74 +451,19 @@ export const useLogsData = () => {
           Boolean(other?.violation_fee_marker);
 
         let content = '';
-        if (!isViolationFeeLog) {
+        if (!isViolationFeeLog && other?.billing_mode !== 'tiered_expr') {
+          const logOpts = {
+            ...other,
+            prompt_tokens: logs[i].prompt_tokens,
+            completion_tokens: logs[i].completion_tokens,
+            displayMode: billingDisplayMode,
+          };
           if (other?.ws || other?.audio) {
-            content = renderAudioModelPrice(
-              other?.text_input,
-              other?.text_output,
-              other?.model_ratio,
-              other?.model_price,
-              other?.completion_ratio,
-              other?.audio_input,
-              other?.audio_output,
-              other?.audio_ratio,
-              other?.audio_completion_ratio,
-              other?.group_ratio,
-              other?.user_group_ratio,
-              other?.cache_tokens || 0,
-              other?.cache_ratio || 1.0,
-              billingDisplayMode,
-            );
+            content = renderAudioModelPrice(logOpts);
           } else if (other?.claude) {
-            content = renderClaudeModelPrice(
-              logs[i].prompt_tokens,
-              logs[i].completion_tokens,
-              other.model_ratio,
-              other.model_price,
-              other.completion_ratio,
-              other.group_ratio,
-              other?.user_group_ratio,
-              other.cache_tokens || 0,
-              other.cache_ratio || 1.0,
-              other.cache_creation_tokens || 0,
-              other.cache_creation_ratio || 1.0,
-              other.cache_creation_tokens_5m || 0,
-              other.cache_creation_ratio_5m ||
-                other.cache_creation_ratio ||
-                1.0,
-              other.cache_creation_tokens_1h || 0,
-              other.cache_creation_ratio_1h ||
-                other.cache_creation_ratio ||
-                1.0,
-              billingDisplayMode,
-            );
+            content = renderClaudeModelPrice(logOpts);
           } else {
-            content = renderModelPrice(
-              logs[i].prompt_tokens,
-              logs[i].completion_tokens,
-              other?.model_ratio,
-              other?.model_price,
-              other?.completion_ratio,
-              other?.group_ratio,
-              other?.user_group_ratio,
-              other?.cache_tokens || 0,
-              other?.cache_ratio || 1.0,
-              other?.image || false,
-              other?.image_ratio || 0,
-              other?.image_output || 0,
-              other?.web_search || false,
-              other?.web_search_call_count || 0,
-              other?.web_search_price || 0,
-              other?.file_search || false,
-              other?.file_search_call_count || 0,
-              other?.file_search_price || 0,
-              other?.audio_input_seperate_price || false,
-              other?.audio_input_token_count || 0,
-              other?.audio_input_price || 0,
-              other?.image_generation_call || false,
-              other?.image_generation_call_price || 0,
-              billingDisplayMode,
-            );
+            content = renderModelPrice(logOpts);
           }
           expandDataLocal.push({
             key: t('计费过程'),
@@ -559,65 +476,15 @@ export const useLogsData = () => {
             value: other.reasoning_effort,
           });
         }
-        if (other?.billing_mode === 'tiered_expr') {
+        if (other?.billing_mode === 'tiered_expr' && other?.expr_b64) {
           expandDataLocal.push({
-            key: t('计费方式'),
-            value: t('阶梯计费'),
-          });
-          if (other?.group_ratio !== undefined) {
-            const gr = other.group_ratio;
-            expandDataLocal.push({
-              key: t('分组倍率'),
-              value: typeof gr === 'number' ? gr.toFixed(4) : String(gr ?? '-'),
-            });
-          }
-          if (other?.rule_version !== undefined) {
-            expandDataLocal.push({
-              key: t('规则版本'),
-              value: String(other.rule_version),
-            });
-          }
-          if (other?.estimated_env) {
-            expandDataLocal.push({
-              key: t('预估环境'),
-              value: `prompt=${other.estimated_env.prompt_tokens ?? 0}, completion=${other.estimated_env.completion_tokens ?? 0}`,
-            });
-          }
-          if (other?.actual_env) {
-            expandDataLocal.push({
-              key: t('实际环境'),
-              value: `prompt=${other.actual_env.prompt_tokens ?? 0}, completion=${other.actual_env.completion_tokens ?? 0}`,
-            });
-          }
-          if (other?.estimated_quota_after_group !== undefined) {
-            expandDataLocal.push({
-              key: t('预估额度'),
-              value: String(other.estimated_quota_after_group),
-            });
-          }
-          if (other?.actual_quota_after_group !== undefined) {
-            expandDataLocal.push({
-              key: t('实际额度'),
-              value: String(other.actual_quota_after_group),
-            });
-          }
-          expandDataLocal.push({
-            key: t('跨阶梯'),
-            value: other?.crossed_tier ? t('是') : t('否'),
+            key: t('计费过程'),
+            value: renderTieredModelPrice({
+              ...other,
+              prompt_tokens: logs[i].prompt_tokens,
+              completion_tokens: logs[i].completion_tokens,
+            }),
           });
-          if (Array.isArray(other?.breakdown) && other.breakdown.length > 0) {
-            const breakdownText = other.breakdown.map((item, idx) =>
-              `[${idx}] ${item.token_type} | tokens=${item.tokens_in_tier} | cost=${item.unit_cost} | flat=${item.flat_fee} | sub=${item.subtotal}`
-            ).join('\n');
-            expandDataLocal.push({
-              key: t('计费明细'),
-              value: (
-                <div style={{ whiteSpace: 'pre-line', fontFamily: 'monospace', fontSize: 12 }}>
-                  {breakdownText}
-                </div>
-              ),
-            });
-          }
         }
       }
       if (logs[i].type === 6) {

+ 18 - 1
web/src/i18n/locales/en.json

@@ -3421,6 +3421,23 @@
     "新年促销": "New Year promo",
     "第 {{n}} 组": "Group {{n}}",
     "0=周日 1=周一 2=周二 3=周三 4=周四 5=周五 6=周六": "0=Sun 1=Mon 2=Tue 3=Wed 4=Thu 5=Fri 6=Sat",
-    "1=一月 ... 12=十二月": "1=Jan ... 12=Dec"
+    "1=一月 ... 12=十二月": "1=Jan ... 12=Dec",
+    "动态计费": "Dynamic pricing",
+    "价格根据用量档位和请求条件动态调整": "Price adjusts dynamically based on usage tiers and request conditions",
+    "分档价格表": "Tiered price table",
+    "条件乘数": "Condition multipliers",
+    "分组倍率": "Group ratio",
+    "将额外乘以上述价格": "will additionally multiply the above prices",
+    "默认": "Default",
+    "缓存读取": "Cache read",
+    "缓存创建": "Cache create",
+    "缓存创建-1h": "Cache create (1h)",
+    "见上方动态计费详情": "See dynamic pricing details above",
+    "分组倍率": "Group ratio",
+    "含时间条件": "Time rules",
+    "含请求条件": "Request rules",
+    "输入": "Input",
+    "输出": "Output",
+    "档": "tiers"
   }
 }

+ 18 - 1
web/src/i18n/locales/zh-CN.json

@@ -3048,6 +3048,23 @@
     "新年促销": "新年促销",
     "第 {{n}} 组": "第 {{n}} 组",
     "0=周日 1=周一 2=周二 3=周三 4=周四 5=周五 6=周六": "0=周日 1=周一 2=周二 3=周三 4=周四 5=周五 6=周六",
-    "1=一月 ... 12=十二月": "1=一月 ... 12=十二月"
+    "1=一月 ... 12=十二月": "1=一月 ... 12=十二月",
+    "动态计费": "动态计费",
+    "价格根据用量档位和请求条件动态调整": "价格根据用量档位和请求条件动态调整",
+    "分档价格表": "分档价格表",
+    "条件乘数": "条件乘数",
+    "分组倍率": "分组倍率",
+    "将额外乘以上述价格": "将额外乘以上述价格",
+    "默认": "默认",
+    "缓存读取": "缓存读取",
+    "缓存创建": "缓存创建",
+    "缓存创建-1h": "缓存创建-1h",
+    "见上方动态计费详情": "见上方动态计费详情",
+    "分组倍率": "分组倍率",
+    "含时间条件": "含时间条件",
+    "含请求条件": "含请求条件",
+    "输入": "输入",
+    "输出": "输出",
+    "档": "档"
   }
 }

+ 18 - 0
web/src/index.css

@@ -865,6 +865,24 @@ html.dark .with-pastel-balls::before {
     height: calc(100vh - 77px);
     max-height: calc(100vh - 77px);
   }
+
+  .semi-input-suffix-text {
+    font-size: 11px;
+    padding: 0;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    max-width: 80px;
+  }
+
+  .semi-input-prefix-text, .semi-input-suffix-text {
+    margin: 0;
+  }
+
+  .semi-select-arrow {
+    margin-left: 2px;
+    margin-right: 2px;
+  }
 }
 
 /* ==================== 模型定价页面布局 ==================== */

+ 1 - 1
web/src/pages/Setting/Ratio/components/ModelPricingEditor.jsx

@@ -324,7 +324,7 @@ export default function ModelPricingEditor({
             gap: 16,
             gridTemplateColumns: isMobile
               ? 'minmax(0, 1fr)'
-              : 'minmax(360px, 1.1fr) minmax(420px, 1fr)',
+              : 'minmax(300px, 0.8fr) minmax(480px, 1.2fr)',
           }}
         >
           <Card

+ 255 - 154
web/src/pages/Setting/Ratio/components/TieredPricingEditor.jsx

@@ -96,11 +96,14 @@ function buildConditionStr(conditions) {
     .join(' && ');
 }
 
-// CACHE_VAR_MAP maps tier data fields to Expr variable names
+// CACHE_VAR_MAP maps tier data fields to Expr variable names (cache + image + audio)
 const CACHE_VAR_MAP = [
   { field: 'cache_read_unit_cost', exprVar: 'cr' },
   { field: 'cache_create_unit_cost', exprVar: 'cc' },
   { field: 'cache_create_1h_unit_cost', exprVar: 'cc1h' },
+  { field: 'image_unit_cost', exprVar: 'img' },
+  { field: 'audio_input_unit_cost', exprVar: 'ai' },
+  { field: 'audio_output_unit_cost', exprVar: 'ao' },
 ];
 
 function getTierCacheMode(tier) {
@@ -130,7 +133,7 @@ function createDefaultVisualConfig() {
         conditions: [],
         input_unit_cost: 0,
         output_unit_cost: 0,
-        label: '默认',
+        label: 'base',
         cache_mode: CACHE_MODE_GENERIC,
       }),
     ],
@@ -270,64 +273,58 @@ function tryParseVisualConfig(exprStr) {
 function ConditionRow({ cond, onChange, onRemove, t }) {
   const hint = formatTokenHint(cond.value);
   return (
-    <div style={{ marginBottom: 6 }}>
-      <div
-        style={{
-          display: 'flex',
-          alignItems: 'center',
-          gap: 6,
-        }}
+    <div style={{
+      marginBottom: 6,
+      display: 'grid',
+      gridTemplateColumns: '1fr auto 1fr auto',
+      gap: '4px 6px',
+      alignItems: 'center',
+    }}>
+      <Select
+        size='small'
+        value={cond.var || 'p'}
+        onChange={(val) => onChange({ ...cond, var: val })}
       >
-        <Select
-          size='small'
-          value={cond.var || 'p'}
-          onChange={(val) => onChange({ ...cond, var: val })}
-          style={{ width: 110 }}
-        >
-          {VAR_OPTIONS.map((v) => (
-            <Select.Option key={v.value} value={v.value}>
-              {v.label}
-            </Select.Option>
-          ))}
-        </Select>
-        <Select
-          size='small'
-          value={cond.op || '<'}
-          onChange={(val) => onChange({ ...cond, op: val })}
-          style={{ width: 70 }}
-        >
-          {OPS.map((op) => (
-            <Select.Option key={op} value={op}>
-              {op}
-            </Select.Option>
-          ))}
-        </Select>
-        <InputNumber
-          size='small'
-          min={0}
-          value={cond.value ?? ''}
-          onChange={(val) => onChange({ ...cond, value: val })}
-          style={{ flex: 1, minWidth: 100 }}
-        />
-        <Button
-          icon={<IconDelete />}
-          type='danger'
-          theme='borderless'
-          size='small'
-          onClick={onRemove}
-        />
-      </div>
+        {VAR_OPTIONS.map((v) => (
+          <Select.Option key={v.value} value={v.value}>
+            {v.label}
+          </Select.Option>
+        ))}
+      </Select>
+      <Select
+        size='small'
+        value={cond.op || '<'}
+        onChange={(val) => onChange({ ...cond, op: val })}
+        style={{ width: 70 }}
+      >
+        {OPS.map((op) => (
+          <Select.Option key={op} value={op}>
+            {op}
+          </Select.Option>
+        ))}
+      </Select>
+      <InputNumber
+        size='small'
+        min={0}
+        value={cond.value ?? ''}
+        onChange={(val) => onChange({ ...cond, value: val })}
+      />
+      <Button
+        icon={<IconDelete />}
+        type='danger'
+        theme='borderless'
+        size='small'
+        onClick={onRemove}
+      />
       {hint ? (
         <Text
           size='small'
           style={{
             color: 'var(--semi-color-text-3)',
-            marginLeft: 186,
-            display: 'block',
-            marginTop: 1,
+            gridColumn: '3 / 4',
           }}
         >
-          {hint}
+          = {hint}
         </Text>
       ) : null}
     </div>
@@ -388,8 +385,8 @@ const CACHE_FIELDS_GENERIC = [
 ];
 
 function ExtendedPriceBlock({ tier, index, onUpdate, t }) {
-  const hasAny = [...CACHE_FIELDS_TIMED].some(
-    (f) => Number(tier[f.field]) > 0,
+  const hasAny = [...CACHE_FIELDS_TIMED, 'image_unit_cost', 'audio_input_unit_cost', 'audio_output_unit_cost'].some(
+    (f) => Number(tier[typeof f === 'string' ? f : f.field]) > 0,
   );
   const [expanded, setExpanded] = useState(hasAny);
   const cacheMode = getTierCacheMode(tier);
@@ -461,6 +458,37 @@ function ExtendedPriceBlock({ tier, index, onUpdate, t }) {
               </div>
             ))}
           </div>
+          <div className='text-xs text-gray-500 mb-2 mt-3'>
+            {t('图片/音频价格(可选)')}
+          </div>
+          <div
+            style={{
+              display: 'grid',
+              gridTemplateColumns: '1fr 1fr 1fr',
+              gap: 8,
+            }}
+          >
+            {[
+              { field: 'image_unit_cost', labelKey: '图片输入价格' },
+              { field: 'audio_input_unit_cost', labelKey: '音频输入价格' },
+              { field: 'audio_output_unit_cost', labelKey: '音频补全价格' },
+            ].map((cf) => (
+              <div key={cf.field}>
+                <Text
+                  size='small'
+                  style={{ color: 'var(--semi-color-text-2)' }}
+                >
+                  {t(cf.labelKey)}
+                </Text>
+                <PriceInput
+                  unitCost={tier[cf.field]}
+                  field={cf.field}
+                  index={index}
+                  onUpdate={onUpdate}
+                />
+              </div>
+            ))}
+          </div>
         </div>
       </Collapsible>
     </div>
@@ -730,73 +758,114 @@ function VisualEditor({ visualConfig, onChange, t }) {
 // Raw Expr editor with preset templates
 // ---------------------------------------------------------------------------
 
-const PRESETS = [
+const PRESET_GROUPS = [
   {
-    key: 'claude-opus',
-    label: 'Claude Opus 4.6',
-    expr: 'tier("default", p * 2.5 + c * 12.5 + cr * 0.25 + cc * 3.125 + cc1h * 5)',
-  },
-  {
-    key: 'claude-opus-fast',
-    label: 'Claude Opus 4.6 Fast',
-    expr: 'tier("default", p * 2.5 + c * 12.5 + cr * 0.25 + cc * 3.125 + cc1h * 5)',
-    requestRules: [
-      { conditions: [{ source: SOURCE_HEADER, path: 'anthropic-beta', mode: MATCH_CONTAINS, value: 'fast-mode-2026-02-01' }], multiplier: '6' },
+    group: '固定价格',
+    presets: [
+      { key: 'flat', label: 'Flat', expr: 'tier("base", p * 1 + c * 2)' },
+      { key: 'claude-opus', label: 'Claude Opus 4.6', expr: 'tier("base", p * 2.5 + c * 12.5 + cr * 0.25 + cc * 3.125 + cc1h * 5)' },
+      { key: 'gpt-5.4', label: 'GPT-5.4', expr: 'tier("base", p * 1.25 + c * 5 + cr * 0.125)' },
     ],
   },
   {
-    key: 'claude-sonnet',
-    label: 'Claude Sonnet 4.5',
-    expr: 'p <= 200000 ? tier("standard", p * 1.5 + c * 7.5 + cr * 0.15 + cc * 1.875 + cc1h * 3) : tier("long_context", p * 3 + c * 11.25 + cr * 0.3 + cc * 3.75 + cc1h * 6)',
-  },
-  {
-    key: 'glm-4.5-air',
-    label: 'GLM-4.5-Air',
-    expr: 'p < 32000 && c < 200 ? tier("short_output", p * 0.4 + c * 1 + cr * 0.08) : p < 32000 && c >= 200 ? tier("long_output", p * 0.4 + c * 3 + cr * 0.08) : tier("mid_context", p * 0.6 + c * 4 + cr * 0.12)',
-  },
-  {
-    key: 'gpt-5.4-fast',
-    label: 'GPT-5.4 Fast',
-    expr: 'tier("default", p * 1.25 + c * 5 + cr * 0.125)',
-    requestRules: [
-      { conditions: [{ source: SOURCE_PARAM, path: 'service_tier', mode: MATCH_EQ, value: 'fast' }], multiplier: '2' },
+    group: '阶梯计费',
+    presets: [
+      { key: 'claude-sonnet', label: 'Claude Sonnet 4.5', expr: 'p <= 200000 ? tier("standard", p * 1.5 + c * 7.5 + cr * 0.15 + cc * 1.875 + cc1h * 3) : tier("long_context", p * 3 + c * 11.25 + cr * 0.3 + cc * 3.75 + cc1h * 6)' },
+      { key: 'qwen3-max', label: 'Qwen3-Max', expr: 'p <= 32000 ? tier("short", p * 0.6 + c * 3 + cr * 0.12 + cc * 0.75) : p <= 128000 ? tier("mid", p * 1.2 + c * 6 + cr * 0.24 + cc * 1.5) : tier("long", p * 1.5 + c * 7.5 + cr * 0.3 + cc * 1.875)' },
+      { key: 'glm-4.5-air', label: 'GLM-4.5-Air', expr: 'p < 32000 && c < 200 ? tier("short_output", p * 0.4 + c * 1 + cr * 0.08) : p < 32000 && c >= 200 ? tier("long_output", p * 0.4 + c * 3 + cr * 0.08) : tier("mid_context", p * 0.6 + c * 4 + cr * 0.12)' },
     ],
   },
   {
-    key: 'flat',
-    label: 'Flat',
-    expr: 'tier("default", p * 1 + c * 2)',
-  },
-  {
-    key: 'night-discount',
-    label: '夜间半价',
-    expr: 'tier("default", p * 1.5 + c * 7.5)',
-    requestRules: [
-      { conditions: [{ source: SOURCE_TIME, timeFunc: 'hour', timezone: 'Asia/Shanghai', mode: MATCH_RANGE, rangeStart: '21', rangeEnd: '6' }], multiplier: '0.5' },
+    group: '多模态',
+    presets: [
+      { key: 'qwen3-omni-flash', label: 'Qwen3-Omni-Flash', expr: 'tier("base", p * 0.215 + c * 1.53 + img * 0.39 + ai * 1.905 + ao * 7.555)' },
     ],
   },
   {
-    key: 'weekend-discount',
-    label: '周末8折',
-    expr: 'tier("default", p * 1.5 + c * 7.5)',
-    requestRules: [
-      { conditions: [{ source: SOURCE_TIME, timeFunc: 'weekday', timezone: 'Asia/Shanghai', mode: MATCH_EQ, value: '0' }], multiplier: '0.8' },
-      { conditions: [{ source: SOURCE_TIME, timeFunc: 'weekday', timezone: 'Asia/Shanghai', mode: MATCH_EQ, value: '6' }], multiplier: '0.8' },
+    group: '请求条件',
+    presets: [
+      {
+        key: 'claude-opus-fast', label: 'Claude Opus 4.6 Fast',
+        expr: 'tier("base", p * 2.5 + c * 12.5 + cr * 0.25 + cc * 3.125 + cc1h * 5)',
+        requestRules: [{ conditions: [{ source: SOURCE_HEADER, path: 'anthropic-beta', mode: MATCH_CONTAINS, value: 'fast-mode-2026-02-01' }], multiplier: '6' }],
+      },
+      {
+        key: 'gpt-5.4-fast', label: 'GPT-5.4 Fast',
+        expr: 'tier("base", p * 1.25 + c * 5 + cr * 0.125)',
+        requestRules: [{ conditions: [{ source: SOURCE_PARAM, path: 'service_tier', mode: MATCH_EQ, value: 'fast' }], multiplier: '2' }],
+      },
     ],
   },
   {
-    key: 'new-year-promo',
-    label: '新年促销',
-    expr: 'tier("default", p * 1.5 + c * 7.5)',
-    requestRules: [
-      { conditions: [
-        { source: SOURCE_TIME, timeFunc: 'month', timezone: 'Asia/Shanghai', mode: MATCH_EQ, value: '1' },
-        { source: SOURCE_TIME, timeFunc: 'day', timezone: 'Asia/Shanghai', mode: MATCH_EQ, value: '1' },
-      ], multiplier: '0.5' },
+    group: '时间促销',
+    presets: [
+      {
+        key: 'night-discount', label: '夜间半价',
+        expr: 'tier("base", p * 1.5 + c * 7.5)',
+        requestRules: [{ conditions: [{ source: SOURCE_TIME, timeFunc: 'hour', timezone: 'Asia/Shanghai', mode: MATCH_RANGE, rangeStart: '21', rangeEnd: '6' }], multiplier: '0.5' }],
+      },
+      {
+        key: 'weekend-discount', label: '周末8折',
+        expr: 'tier("base", p * 1.5 + c * 7.5)',
+        requestRules: [
+          { conditions: [{ source: SOURCE_TIME, timeFunc: 'weekday', timezone: 'Asia/Shanghai', mode: MATCH_EQ, value: '0' }], multiplier: '0.8' },
+          { conditions: [{ source: SOURCE_TIME, timeFunc: 'weekday', timezone: 'Asia/Shanghai', mode: MATCH_EQ, value: '6' }], multiplier: '0.8' },
+        ],
+      },
+      {
+        key: 'new-year-promo', label: '新年促销',
+        expr: 'tier("base", p * 1.5 + c * 7.5)',
+        requestRules: [{ conditions: [
+          { source: SOURCE_TIME, timeFunc: 'month', timezone: 'Asia/Shanghai', mode: MATCH_EQ, value: '1' },
+          { source: SOURCE_TIME, timeFunc: 'day', timezone: 'Asia/Shanghai', mode: MATCH_EQ, value: '1' },
+        ], multiplier: '0.5' }],
+      },
     ],
   },
 ];
 
+const PRESET_DEFAULT_VISIBLE = 2;
+
+function PresetSection({ applyPreset, t }) {
+  const [expanded, setExpanded] = useState(false);
+  const visibleGroups = expanded ? PRESET_GROUPS : PRESET_GROUPS.slice(0, PRESET_DEFAULT_VISIBLE);
+  const hasMore = PRESET_GROUPS.length > PRESET_DEFAULT_VISIBLE;
+
+  return (
+    <div style={{ marginBottom: 12 }}>
+      <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
+        <Text size='small' style={{ color: 'var(--semi-color-text-2)' }}>
+          {t('预设模板')}
+        </Text>
+        {hasMore && (
+          <Button
+            theme='borderless'
+            size='small'
+            onClick={() => setExpanded(!expanded)}
+            style={{ padding: '0 4px', fontSize: 12, color: 'var(--semi-color-primary)' }}
+          >
+            {expanded ? t('收起') : t('更多模板...')}
+          </Button>
+        )}
+      </div>
+      <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
+        {visibleGroups.map((g) => (
+          <div key={g.group} style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
+            <Tag size='small' color='grey' style={{ minWidth: 60, textAlign: 'center' }}>
+              {t(g.group)}
+            </Tag>
+            {g.presets.map((p) => (
+              <Button key={p.key} size='small' theme='light' onClick={() => applyPreset(p)}>
+                {p.label}
+              </Button>
+            ))}
+          </div>
+        ))}
+      </div>
+    </div>
+  );
+}
+
 function RawExprEditor({ exprString, onChange, t }) {
   return (
     <div>
@@ -843,10 +912,13 @@ function RawExprEditor({ exprString, onChange, t }) {
 // Cache token inputs for estimator — auto-shown when expression uses cache vars
 // ---------------------------------------------------------------------------
 
-const CACHE_ESTIMATOR_FIELDS = [
+const EXTRA_ESTIMATOR_FIELDS = [
   { var: 'cr', stateKey: 'cacheReadTokens', labelKey: '缓存读取 Token (cr)' },
   { var: 'cc', stateKey: 'cacheCreateTokens', labelKey: '缓存创建 Token (cc)' },
   { var: 'cc1h', stateKey: 'cacheCreate1hTokens', labelKey: '缓存创建-1小时 (cc1h)' },
+  { var: 'img', stateKey: 'imageTokens', labelKey: '图片输入 Token (img)' },
+  { var: 'ai', stateKey: 'audioInputTokens', labelKey: '音频输入 Token (ai)' },
+  { var: 'ao', stateKey: 'audioOutputTokens', labelKey: '音频补全 Token (ao)' },
 ];
 
 function CacheTokenEstimatorInputs({
@@ -854,25 +926,34 @@ function CacheTokenEstimatorInputs({
   cacheReadTokens, setCacheReadTokens,
   cacheCreateTokens, setCacheCreateTokens,
   cacheCreate1hTokens, setCacheCreate1hTokens,
+  imageTokens, setImageTokens,
+  audioInputTokens, setAudioInputTokens,
+  audioOutputTokens, setAudioOutputTokens,
   t,
 }) {
   const setters = {
     cacheReadTokens: setCacheReadTokens,
     cacheCreateTokens: setCacheCreateTokens,
     cacheCreate1hTokens: setCacheCreate1hTokens,
+    imageTokens: setImageTokens,
+    audioInputTokens: setAudioInputTokens,
+    audioOutputTokens: setAudioOutputTokens,
   };
   const values = {
     cacheReadTokens,
     cacheCreateTokens,
     cacheCreate1hTokens,
+    imageTokens,
+    audioInputTokens,
+    audioOutputTokens,
   };
 
-  const usesCache = useMemo(() => {
+  const usesExtra = useMemo(() => {
     if (!effectiveExpr) return false;
-    return /\b(cr|cc1h|cc)\b/.test(effectiveExpr);
+    return /\b(cr|cc1h|cc|img|ai|ao)\b/.test(effectiveExpr);
   }, [effectiveExpr]);
 
-  if (!usesCache) return null;
+  if (!usesExtra) return null;
 
   return (
     <div
@@ -883,7 +964,7 @@ function CacheTokenEstimatorInputs({
         marginBottom: 12,
       }}
     >
-      {CACHE_ESTIMATOR_FIELDS.map((cf) => (
+      {EXTRA_ESTIMATOR_FIELDS.map((cf) => (
         <div key={cf.var}>
           <Text size='small' className='mb-1' style={{ display: 'block' }}>
             {t(cf.labelKey)}
@@ -904,7 +985,7 @@ function CacheTokenEstimatorInputs({
 // Cost estimator (works with any Expr string)
 // ---------------------------------------------------------------------------
 
-function evalExprLocally(exprStr, p, c, cr, cc, cc1h) {
+function evalExprLocally(exprStr, p, c, cr, cc, cc1h, img, ai, ao) {
   try {
     let matchedTier = '';
     const tierFn = (name, value) => {
@@ -917,6 +998,17 @@ function evalExprLocally(exprStr, p, c, cr, cc, cc1h) {
       cr: cr || 0,
       cc: cc || 0,
       cc1h: cc1h || 0,
+      img: img || 0,
+      ai: ai || 0,
+      ao: ao || 0,
+      prompt_tokens: p,
+      completion_tokens: c,
+      cache_read_tokens: cr || 0,
+      cache_create_tokens: cc || 0,
+      cache_create_1h_tokens: cc1h || 0,
+      image_tokens: img || 0,
+      audio_input_tokens: ai || 0,
+      audio_output_tokens: ao || 0,
       tier: tierFn,
       max: Math.max,
       min: Math.min,
@@ -995,37 +1087,47 @@ function RuleConditionRow({ cond, onChange, onRemove, t }) {
     const ph = TIME_FUNC_PLACEHOLDERS[normalized.timeFunc] || '';
     const hint = TIME_FUNC_HINTS[normalized.timeFunc] || '';
     return (
-      <div style={{ marginBottom: 6 }}>
-        <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
+      <div style={{
+        marginBottom: 8,
+        padding: '8px 10px',
+        borderRadius: 6,
+        background: 'var(--semi-color-fill-0)',
+        display: 'flex',
+        flexDirection: 'column',
+        gap: 6,
+      }}>
+        <div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
           {sourceSelect}
           <Select
             size='small'
             value={normalized.timeFunc}
             onChange={(value) => onChange({ ...normalized, timeFunc: value })}
-            style={{ width: 80 }}
+            style={{ flex: 1 }}
           >
             {TIME_FUNCS.map((fn) => (
               <Select.Option key={fn} value={fn}>{t(TIME_FUNC_LABELS[fn] || fn)}</Select.Option>
             ))}
           </Select>
-          <Select
-            size='small'
-            value={normalized.timezone}
-            onChange={(value) => onChange({ ...normalized, timezone: value })}
-            filter
-            allowCreate
-            placeholder={t('时区')}
-            style={{ width: 180 }}
-          >
-            {COMMON_TIMEZONES.map((tz) => (
-              <Select.Option key={tz.value} value={tz.value}>{tz.label}</Select.Option>
-            ))}
-          </Select>
+          {removeBtn}
+        </div>
+        <Select
+          size='small'
+          value={normalized.timezone}
+          onChange={(value) => onChange({ ...normalized, timezone: value })}
+          filter
+          allowCreate
+          placeholder={t('时区')}
+        >
+          {COMMON_TIMEZONES.map((tz) => (
+            <Select.Option key={tz.value} value={tz.value}>{tz.label}</Select.Option>
+          ))}
+        </Select>
+        <div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
           <Select
             size='small'
             value={normalized.mode}
             onChange={(value) => onChange(normalizeCondition({ ...normalized, mode: value }))}
-            style={{ width: 100 }}
+            style={{ flex: 1 }}
           >
             {matchOptions.map((item) => (
               <Select.Option key={item.value} value={item.value}>{item.label}</Select.Option>
@@ -1038,12 +1140,11 @@ function RuleConditionRow({ cond, onChange, onRemove, t }) {
               <Input size='small' value={normalized.rangeEnd} placeholder={ph} style={{ flex: 1 }} onChange={(value) => onChange({ ...normalized, rangeEnd: value })} />
             </div>
           ) : (
-            <Input size='small' value={normalized.value} placeholder={ph} style={{ flex: 1, minWidth: 60 }} onChange={(value) => onChange({ ...normalized, value })} />
+            <Input size='small' value={normalized.value} placeholder={ph} style={{ flex: 1 }} onChange={(value) => onChange({ ...normalized, value })} />
           )}
-          {removeBtn}
         </div>
         {hint && (
-          <Text size='small' style={{ color: 'var(--semi-color-text-3)', marginLeft: 116, marginTop: 2, display: 'block' }}>
+          <Text size='small' style={{ color: 'var(--semi-color-text-3)' }}>
             {t(hint)}
           </Text>
         )}
@@ -1053,20 +1154,27 @@ function RuleConditionRow({ cond, onChange, onRemove, t }) {
 
   const showValue = normalized.mode !== MATCH_EXISTS;
   return (
-    <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6 }}>
+    <div style={{
+      marginBottom: 8,
+      padding: '8px 10px',
+      borderRadius: 6,
+      background: 'var(--semi-color-fill-0)',
+      display: 'grid',
+      gridTemplateColumns: '1fr 1fr auto',
+      gap: '6px 8px',
+    }}>
       {sourceSelect}
       <Input
         size='small'
         value={normalized.path}
         placeholder={normalized.source === SOURCE_HEADER ? t('例如 anthropic-beta') : t('例如 service_tier')}
         onChange={(value) => onChange({ ...normalized, path: value })}
-        style={{ flex: 1, minWidth: 120 }}
       />
+      {removeBtn}
       <Select
         size='small'
         value={normalized.mode}
         onChange={(value) => onChange(normalizeCondition({ ...normalized, mode: value, value: value === MATCH_EXISTS ? '' : normalized.value }))}
-        style={{ width: 100 }}
       >
         {matchOptions.map((item) => (
           <Select.Option key={item.value} value={item.value}>{item.label}</Select.Option>
@@ -1078,9 +1186,8 @@ function RuleConditionRow({ cond, onChange, onRemove, t }) {
         placeholder={normalized.mode === MATCH_CONTAINS ? t('匹配内容') : normalized.mode === MATCH_EXISTS ? '' : t('匹配值')}
         disabled={!showValue}
         onChange={(value) => onChange({ ...normalized, value })}
-        style={{ flex: 1, minWidth: 80 }}
       />
-      {removeBtn}
+      <div />
     </div>
   );
 }
@@ -1172,6 +1279,9 @@ export default function TieredPricingEditor({ model, onExprChange, requestRuleEx
   const [cacheReadTokens, setCacheReadTokens] = useState(0);
   const [cacheCreateTokens, setCacheCreateTokens] = useState(0);
   const [cacheCreate1hTokens, setCacheCreate1hTokens] = useState(0);
+  const [imageTokens, setImageTokens] = useState(0);
+  const [audioInputTokens, setAudioInputTokens] = useState(0);
+  const [audioOutputTokens, setAudioOutputTokens] = useState(0);
 
   const currentRequestRuleExpr = requestRuleExpr || '';
   const parsedRequestRuleGroups = useMemo(
@@ -1282,9 +1392,11 @@ export default function TieredPricingEditor({ model, onExprChange, requestRuleEx
     () => evalExprLocally(
       effectiveExpr, promptTokens, completionTokens,
       cacheReadTokens, cacheCreateTokens, cacheCreate1hTokens,
+      imageTokens, audioInputTokens, audioOutputTokens,
     ),
     [effectiveExpr, promptTokens, completionTokens,
-      cacheReadTokens, cacheCreateTokens, cacheCreate1hTokens],
+      cacheReadTokens, cacheCreateTokens, cacheCreate1hTokens,
+      imageTokens, audioInputTokens, audioOutputTokens],
   );
 
   return (
@@ -1301,24 +1413,7 @@ export default function TieredPricingEditor({ model, onExprChange, requestRuleEx
         </RadioGroup>
       </div>
 
-      <div style={{ marginBottom: 12, display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 6 }}>
-        <Text
-          size='small'
-          style={{ color: 'var(--semi-color-text-2)' }}
-        >
-          {t('预设模板')}:
-        </Text>
-        {PRESETS.map((p) => (
-          <Button
-            key={p.key}
-            size='small'
-            theme='light'
-            onClick={() => applyPreset(p)}
-          >
-            {p.label}
-          </Button>
-        ))}
-      </div>
+      <PresetSection applyPreset={applyPreset} t={t} />
 
       <Card
         bodyStyle={{ padding: 16 }}
@@ -1440,6 +1535,12 @@ export default function TieredPricingEditor({ model, onExprChange, requestRuleEx
           setCacheCreateTokens={setCacheCreateTokens}
           cacheCreate1hTokens={cacheCreate1hTokens}
           setCacheCreate1hTokens={setCacheCreate1hTokens}
+          imageTokens={imageTokens}
+          setImageTokens={setImageTokens}
+          audioInputTokens={audioInputTokens}
+          setAudioInputTokens={setAudioInputTokens}
+          audioOutputTokens={audioOutputTokens}
+          setAudioOutputTokens={setAudioOutputTokens}
           t={t}
         />
         <div

+ 10 - 10
web/src/pages/Setting/Ratio/components/requestRuleExpr.js

@@ -14,17 +14,17 @@ export const MATCH_RANGE = 'range';
 export const TIME_FUNCS = ['hour', 'minute', 'weekday', 'month', 'day'];
 
 export const COMMON_TIMEZONES = [
-  { value: 'Asia/Shanghai', label: 'CST (UTC+8 北京)' },
+  { value: 'Asia/Shanghai', label: 'UTC+8 北京 (Asia/Shanghai)' },
   { value: 'UTC', label: 'UTC' },
-  { value: 'America/New_York', label: 'EST (UTC-5 纽约)' },
-  { value: 'America/Los_Angeles', label: 'PST (UTC-8 洛杉矶)' },
-  { value: 'America/Chicago', label: 'CST (UTC-6 芝加哥)' },
-  { value: 'Europe/London', label: 'GMT (UTC+0 伦敦)' },
-  { value: 'Europe/Berlin', label: 'CET (UTC+1 柏林)' },
-  { value: 'Asia/Tokyo', label: 'JST (UTC+9 东京)' },
-  { value: 'Asia/Singapore', label: 'SGT (UTC+8 新加坡)' },
-  { value: 'Asia/Seoul', label: 'KST (UTC+9 首尔)' },
-  { value: 'Australia/Sydney', label: 'AEST (UTC+10 悉尼)' },
+  { value: 'America/New_York', label: 'UTC-5 纽约 (America/New_York)' },
+  { value: 'America/Los_Angeles', label: 'UTC-8 洛杉矶 (America/Los_Angeles)' },
+  { value: 'America/Chicago', label: 'UTC-6 芝加哥 (America/Chicago)' },
+  { value: 'Europe/London', label: 'UTC+0 伦敦 (Europe/London)' },
+  { value: 'Europe/Berlin', label: 'UTC+1 柏林 (Europe/Berlin)' },
+  { value: 'Asia/Tokyo', label: 'UTC+9 东京 (Asia/Tokyo)' },
+  { value: 'Asia/Singapore', label: 'UTC+8 新加坡 (Asia/Singapore)' },
+  { value: 'Asia/Seoul', label: 'UTC+9 首尔 (Asia/Seoul)' },
+  { value: 'Australia/Sydney', label: 'UTC+10 悉尼 (Australia/Sydney)' },
 ];
 
 export const NUMERIC_LITERAL_REGEX =

+ 19 - 1
web/src/pages/Setting/Ratio/hooks/useModelPricingEditorState.js

@@ -1,3 +1,21 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
 import { useEffect, useMemo, useState } from 'react';
 import { API, showError, showSuccess } from '../../../../helpers';
 import {
@@ -859,7 +877,7 @@ export function useModelPricingEditorState({
     upsertModel(selectedModel.name, (model) => {
       const next = { ...model, billingMode: value };
       if (value === 'tiered_expr' && !model.billingExpr) {
-        next.billingExpr = 'tier("default", p * 0 + c * 0)';
+        next.billingExpr = 'tier("base", p * 0 + c * 0)';
       }
       return next;
     });