diff --git a/model/pricing.go b/model/pricing.go
index 54ae9845..0fee7cae 100644
--- a/model/pricing.go
+++ b/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)
}
diff --git a/pkg/billingexpr/billingexpr_test.go b/pkg/billingexpr/billingexpr_test.go
index 34ec0fc0..0ca47ab6 100644
--- a/pkg/billingexpr/billingexpr_test.go
+++ b/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)
+ }
+}
diff --git a/pkg/billingexpr/compile.go b/pkg/billingexpr/compile.go
index 4a8b61e9..edb77217 100644
--- a/pkg/billingexpr/compile.go
+++ b/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 },
diff --git a/pkg/billingexpr/run.go b/pkg/billingexpr/run.go
index 267c5af2..de641766 100644
--- a/pkg/billingexpr/run.go
+++ b/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
diff --git a/pkg/billingexpr/types.go b/pkg/billingexpr/types.go
index 193f82b4..dd626aae 100644
--- a/pkg/billingexpr/types.go
+++ b/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
diff --git a/relay/compatible_handler.go b/relay/compatible_handler.go
index 392677af..63a9d2ee 100644
--- a/relay/compatible_handler.go
+++ b/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,
- })
-}
diff --git a/service/log_info_generate.go b/service/log_info_generate.go
index 0737a911..6d4e021c 100644
--- a/service/log_info_generate.go
+++ b/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
}
diff --git a/service/quota.go b/service/quota.go
index eafec351..4341898c 100644
--- a/service/quota.go
+++ b/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,
- })
-}
diff --git a/web/src/components/table/model-pricing/modal/ModelDetailSideSheet.jsx b/web/src/components/table/model-pricing/modal/ModelDetailSideSheet.jsx
index d547b7f4..06479cb8 100644
--- a/web/src/components/table/model-pricing/modal/ModelDetailSideSheet.jsx
+++ b/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 && (
+
+ )}
.
+
+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 (
+
+
+
+ {billingExpr}
+
+
+ );
+ }
+
+ const tierColumns = [
+ {
+ title: t('档位'),
+ dataIndex: 'label',
+ render: (text, record) => (
+
+
{text || t('默认')}
+ {record.condSummary && (
+
{record.condSummary}
+ )}
+
+ ),
+ },
+ {
+ title: `${t('输入价格')} (${PRICE_SUFFIX})`,
+ dataIndex: 'inputPrice',
+ render: (v) => ${v.toFixed(4)},
+ },
+ {
+ title: `${t('输出价格')} (${PRICE_SUFFIX})`,
+ dataIndex: 'outputPrice',
+ render: (v) => ${v.toFixed(4)},
+ },
+ ];
+
+ const hasCacheRead = hasTiers && tiers.some((tier) => tier.cacheReadPrice != null);
+ const hasCacheCreate = hasTiers && tiers.some((tier) => tier.cacheCreatePrice != null);
+ const hasCache1h = hasTiers && tiers.some((tier) => tier.cacheCreate1hPrice != null);
+
+ if (hasCacheRead) {
+ tierColumns.push({
+ title: `${t('缓存读取')} (${PRICE_SUFFIX})`,
+ dataIndex: 'cacheReadPrice',
+ render: (v) => v != null ? ${v.toFixed(4)} : '-',
+ });
+ }
+ if (hasCacheCreate) {
+ tierColumns.push({
+ title: `${t('缓存创建')} (${PRICE_SUFFIX})`,
+ dataIndex: 'cacheCreatePrice',
+ render: (v) => v != null ? ${v.toFixed(4)} : '-',
+ });
+ }
+ if (hasCache1h) {
+ tierColumns.push({
+ title: `${t('缓存创建-1h')} (${PRICE_SUFFIX})`,
+ dataIndex: 'cacheCreate1hPrice',
+ render: (v) => v != null ? ${v.toFixed(4)} : '-',
+ });
+ }
+
+ const tierData = hasTiers
+ ? tiers.map((tier, i) => ({
+ key: `tier-${i}`,
+ label: tier.label,
+ condSummary: formatConditionSummary(tier.conditions, t),
+ inputPrice: tier.inputPrice,
+ outputPrice: tier.outputPrice,
+ cacheReadPrice: tier.cacheReadPrice,
+ cacheCreatePrice: tier.cacheCreatePrice,
+ cacheCreate1hPrice: tier.cacheCreate1hPrice,
+ }))
+ : [];
+
+ return (
+
+
+
+
+
+
+
{t('动态计费')}
+
+ {t('价格根据用量档位和请求条件动态调整')}
+
+
+
+
+ {hasTiers && (
+
+ )}
+
+ {hasRules && (
+
+
+ {t('条件乘数')}
+
+ {ruleGroups.map((group, gi) => (
+
+ {describeGroup(group, t)}
+ {group.multiplier}x
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/web/src/components/table/model-pricing/modal/components/ModelPricingTable.jsx b/web/src/components/table/model-pricing/modal/components/ModelPricingTable.jsx
index b2064609..1427b9b9 100644
--- a/web/src/components/table/model-pricing/modal/components/ModelPricingTable.jsx
+++ b/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) => (
-
+
{text}x
),
});
}
- // 添加计费类型列
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 (
{text || '-'}
@@ -126,18 +130,27 @@ const ModelPricingTable = ({
columns.push({
title: siteDisplayType === 'TOKENS' ? t('计费摘要') : t('价格摘要'),
dataIndex: 'priceItems',
- render: (items) => (
-
- {items.map((item) => (
-
-
- {item.label} {item.value}
+ render: (items) => {
+ if (items.length === 1 && items[0].isDynamic) {
+ return (
+
+ {t('见上方动态计费详情')}
+
+ );
+ }
+ return (
+
+ {items.map((item) => (
+
+
+ {item.label} {item.value}
+
+
{item.suffix}
-
{item.suffix}
-
- ))}
-
- ),
+ ))}
+
+ );
+ },
});
return (
diff --git a/web/src/components/table/model-pricing/view/card/PricingCardView.jsx b/web/src/components/table/model-pricing/view/card/PricingCardView.jsx
index 477da259..77aa1a39 100644
--- a/web/src/components/table/model-pricing/view/card/PricingCardView.jsx
+++ b/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}
- {formatPriceInfo(priceData, t, siteDisplayType)}
+ {priceData.isDynamicPricing ? (
+ formatDynamicPriceSummary(priceData.billingExpr, t)
+ ) : (
+ formatPriceInfo(priceData, t, siteDisplayType)
+ )}
diff --git a/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx b/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx
index 6f5021b4..8785927d 100644
--- a/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx
+++ b/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' }),
};
}
diff --git a/web/src/helpers/render.jsx b/web/src/helpers/render.jsx
index 28da657f..ad7daadd 100644
--- a/web/src/helpers/render.jsx
+++ b/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();
diff --git a/web/src/helpers/utils.jsx b/web/src/helpers/utils.jsx
index 435a11ed..6f68528a 100644
--- a/web/src/helpers/utils.jsx
+++ b/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 {t('动态计费')};
+
+ 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 && (
+ <>
+
+ {t('输入价格')} ${(Number(firstTierMatch[1]) * 2).toFixed(4)}{unitSuffix}
+
+
+ {t('输出价格')} ${(Number(firstTierMatch[2]) * 2).toFixed(4)}{unitSuffix}
+
+ {firstTierMatch[3] && (
+
+ {t('缓存读取价格')} ${(Number(firstTierMatch[3]) * 2).toFixed(4)}{unitSuffix}
+
+ )}
+ {firstTierMatch[4] && (
+
+ {t('缓存创建价格')} ${(Number(firstTierMatch[4]) * 2).toFixed(4)}{unitSuffix}
+
+ )}
+ >
+ )}
+
+
+ {t('动态计费')}
+
+ {tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+ >
+ );
+};
+
// 格式化价格信息(用于卡片视图)
export const formatPriceInfo = (priceData, t, quotaDisplayType = 'USD') => {
const items = getModelPriceItems(priceData, t, quotaDisplayType);
diff --git a/web/src/hooks/usage-logs/useUsageLogsData.jsx b/web/src/hooks/usage-logs/useUsageLogsData.jsx
index 84019a22..b961d7a6 100644
--- a/web/src/hooks/usage-logs/useUsageLogsData.jsx
+++ b/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('阶梯计费'),
+ key: t('计费过程'),
+ value: renderTieredModelPrice({
+ ...other,
+ prompt_tokens: logs[i].prompt_tokens,
+ completion_tokens: logs[i].completion_tokens,
+ }),
});
- 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('否'),
- });
- 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: (
-
- {breakdownText}
-
- ),
- });
- }
}
}
if (logs[i].type === 6) {
diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json
index 583d525c..60002614 100644
--- a/web/src/i18n/locales/en.json
+++ b/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"
}
}
diff --git a/web/src/i18n/locales/zh-CN.json b/web/src/i18n/locales/zh-CN.json
index ccbdee91..67f60bef 100644
--- a/web/src/i18n/locales/zh-CN.json
+++ b/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",
+ "见上方动态计费详情": "见上方动态计费详情",
+ "分组倍率": "分组倍率",
+ "含时间条件": "含时间条件",
+ "含请求条件": "含请求条件",
+ "输入": "输入",
+ "输出": "输出",
+ "档": "档"
}
}
diff --git a/web/src/index.css b/web/src/index.css
index b01a2a04..2ad04189 100644
--- a/web/src/index.css
+++ b/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;
+ }
}
/* ==================== 模型定价页面布局 ==================== */
diff --git a/web/src/pages/Setting/Ratio/components/ModelPricingEditor.jsx b/web/src/pages/Setting/Ratio/components/ModelPricingEditor.jsx
index 0a6bb45b..2beafe01 100644
--- a/web/src/pages/Setting/Ratio/components/ModelPricingEditor.jsx
+++ b/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)',
}}
>
-
+
+ {VAR_OPTIONS.map((v) => (
+
+ {v.label}
+
+ ))}
+
+ onChange({ ...cond, op: val })}
+ style={{ width: 70 }}
+ >
+ {OPS.map((op) => (
+
+ {op}
+
+ ))}
+
+ onChange({ ...cond, value: val })}
+ />
+ }
+ type='danger'
+ theme='borderless'
+ size='small'
+ onClick={onRemove}
+ />
{hint ? (
- {hint}
+ = {hint}
) : null}
@@ -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 }) {
))}
+
+ {t('图片/音频价格(可选)')}
+
+
+ {[
+ { field: 'image_unit_cost', labelKey: '图片输入价格' },
+ { field: 'audio_input_unit_cost', labelKey: '音频输入价格' },
+ { field: 'audio_output_unit_cost', labelKey: '音频补全价格' },
+ ].map((cf) => (
+
+
+ {t(cf.labelKey)}
+
+
+
+ ))}
+
@@ -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 (
+
+
+
+ {t('预设模板')}
+
+ {hasMore && (
+
+ )}
+
+
+ {visibleGroups.map((g) => (
+
+
+ {t(g.group)}
+
+ {g.presets.map((p) => (
+
+ ))}
+
+ ))}
+
+
+ );
+}
+
function RawExprEditor({ exprString, onChange, t }) {
return (
@@ -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 (
- {CACHE_ESTIMATOR_FIELDS.map((cf) => (
+ {EXTRA_ESTIMATOR_FIELDS.map((cf) => (
{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 (
-
-
+
+
{sourceSelect}
onChange({ ...normalized, timeFunc: value })}
- style={{ width: 80 }}
+ style={{ flex: 1 }}
>
{TIME_FUNCS.map((fn) => (
{t(TIME_FUNC_LABELS[fn] || fn)}
))}
- onChange({ ...normalized, timezone: value })}
- filter
- allowCreate
- placeholder={t('时区')}
- style={{ width: 180 }}
- >
- {COMMON_TIMEZONES.map((tz) => (
- {tz.label}
- ))}
-
+ {removeBtn}
+
+
onChange({ ...normalized, timezone: value })}
+ filter
+ allowCreate
+ placeholder={t('时区')}
+ >
+ {COMMON_TIMEZONES.map((tz) => (
+ {tz.label}
+ ))}
+
+
onChange(normalizeCondition({ ...normalized, mode: value }))}
- style={{ width: 100 }}
+ style={{ flex: 1 }}
>
{matchOptions.map((item) => (
{item.label}
@@ -1038,12 +1140,11 @@ function RuleConditionRow({ cond, onChange, onRemove, t }) {
onChange({ ...normalized, rangeEnd: value })} />
) : (
-
onChange({ ...normalized, value })} />
+
onChange({ ...normalized, value })} />
)}
- {removeBtn}
{hint && (
-
+
{t(hint)}
)}
@@ -1053,20 +1154,27 @@ function RuleConditionRow({ cond, onChange, onRemove, t }) {
const showValue = normalized.mode !== MATCH_EXISTS;
return (
-
+
{sourceSelect}
onChange({ ...normalized, path: value })}
- style={{ flex: 1, minWidth: 120 }}
/>
+ {removeBtn}
onChange(normalizeCondition({ ...normalized, mode: value, value: value === MATCH_EXISTS ? '' : normalized.value }))}
- style={{ width: 100 }}
>
{matchOptions.map((item) => (
{item.label}
@@ -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}
+
);
}
@@ -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
-
-
- {t('预设模板')}:
-
- {PRESETS.map((p) => (
-
- ))}
-
+
.
+
+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;
});