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.
This commit is contained in:
CaIon 2026-03-16 18:57:14 +08:00
parent 91ed4e196a
commit f0589cc478
23 changed files with 1237 additions and 695 deletions

View File

@ -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)
}

View File

@ -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)
}
}

View File

@ -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 },

View File

@ -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

View File

@ -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

View File

@ -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,
})
}

View File

@ -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
}

View File

@ -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,
})
}

View File

@ -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}

View File

@ -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>
);
}

View File

@ -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 (

View File

@ -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>

View File

@ -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' }),
};
}

View File

@ -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();

View File

@ -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);

View File

@ -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: (
<div style={{ whiteSpace: 'pre-line', fontFamily: 'monospace', fontSize: 12 }}>
{breakdownText}
</div>
),
});
}
}
}
if (logs[i].type === 6) {

View File

@ -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"
}
}

View File

@ -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
web/src/index.css vendored
View File

@ -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;
}
}
/* ==================== 模型定价页面布局 ==================== */

View File

@ -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

View File

@ -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

View File

@ -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 =

View File

@ -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;
});