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:
parent
91ed4e196a
commit
f0589cc478
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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 (
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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' }),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
406
web/src/helpers/render.jsx
vendored
406
web/src/helpers/render.jsx
vendored
@ -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();
|
||||
|
||||
101
web/src/helpers/utils.jsx
vendored
101
web/src/helpers/utils.jsx
vendored
@ -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);
|
||||
|
||||
185
web/src/hooks/usage-logs/useUsageLogsData.jsx
vendored
185
web/src/hooks/usage-logs/useUsageLogsData.jsx
vendored
@ -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) {
|
||||
|
||||
19
web/src/i18n/locales/en.json
vendored
19
web/src/i18n/locales/en.json
vendored
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
19
web/src/i18n/locales/zh-CN.json
vendored
19
web/src/i18n/locales/zh-CN.json
vendored
@ -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
18
web/src/index.css
vendored
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== 模型定价页面布局 ==================== */
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 =
|
||||
|
||||
@ -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;
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user