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/common"
|
||||||
"github.com/QuantumNous/new-api/constant"
|
"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/setting/ratio_setting"
|
||||||
"github.com/QuantumNous/new-api/types"
|
"github.com/QuantumNous/new-api/types"
|
||||||
)
|
)
|
||||||
@ -32,6 +33,8 @@ type Pricing struct {
|
|||||||
AudioCompletionRatio *float64 `json:"audio_completion_ratio,omitempty"`
|
AudioCompletionRatio *float64 `json:"audio_completion_ratio,omitempty"`
|
||||||
EnableGroup []string `json:"enable_groups"`
|
EnableGroup []string `json:"enable_groups"`
|
||||||
SupportedEndpointTypes []constant.EndpointType `json:"supported_endpoint_types"`
|
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"`
|
PricingVersion string `json:"pricing_version,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -319,6 +322,12 @@ func updatePricing() {
|
|||||||
audioCompletionRatio := ratio_setting.GetAudioCompletionRatio(model)
|
audioCompletionRatio := ratio_setting.GetAudioCompletionRatio(model)
|
||||||
pricing.AudioCompletionRatio = &audioCompletionRatio
|
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)
|
pricingMap = append(pricingMap, pricing)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -931,3 +931,55 @@ func TestTimeFunctions_MonthDayPattern(t *testing.T) {
|
|||||||
t.Errorf("cost = %f, want 1000 or 500", cost)
|
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_read_tokens": float64(0),
|
||||||
"cache_create_tokens": float64(0),
|
"cache_create_tokens": float64(0),
|
||||||
"cache_create_1h_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 },
|
"tier": func(string, float64) float64 { return 0 },
|
||||||
"header": func(string) string { return "" },
|
"header": func(string) string { return "" },
|
||||||
"param": func(string) interface{} { return nil },
|
"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_read_tokens": params.CR,
|
||||||
"cache_create_tokens": params.CC,
|
"cache_create_tokens": params.CC,
|
||||||
"cache_create_1h_tokens": params.CC1h,
|
"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 {
|
"tier": func(name string, value float64) float64 {
|
||||||
trace.MatchedTier = name
|
trace.MatchedTier = name
|
||||||
trace.Cost = value
|
trace.Cost = value
|
||||||
|
|||||||
@ -14,11 +14,14 @@ type RequestInput struct {
|
|||||||
// Fields beyond P and C are optional — when absent they default to 0,
|
// Fields beyond P and C are optional — when absent they default to 0,
|
||||||
// which means cache-unaware expressions keep working unchanged.
|
// which means cache-unaware expressions keep working unchanged.
|
||||||
type TokenParams struct {
|
type TokenParams struct {
|
||||||
P float64 // prompt tokens
|
P float64 // prompt tokens (text)
|
||||||
C float64 // completion tokens
|
C float64 // completion tokens (text)
|
||||||
CR float64 // cache read (hit) tokens
|
CR float64 // cache read (hit) tokens
|
||||||
CC float64 // cache creation tokens (5-min TTL for Claude, generic for others)
|
CC float64 // cache creation tokens (5-min TTL for Claude, generic for others)
|
||||||
CC1h float64 // cache creation tokens — 1-hour TTL (Claude only)
|
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
|
// 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())
|
service.ObserveChannelAffinityUsageCacheByRelayFormat(ctx, usage, relayInfo.GetFinalRequestRelayFormat())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tiered billing early return
|
// Tiered billing: only determines quota, logging continues through normal path
|
||||||
if ok, tieredQuota, tieredResult := service.TryTieredSettle(relayInfo, billingexpr.TokenParams{
|
var tieredResult *billingexpr.TieredResult
|
||||||
|
tieredOk, tieredQuota, tieredRes := service.TryTieredSettle(relayInfo, billingexpr.TokenParams{
|
||||||
P: float64(usage.PromptTokens),
|
P: float64(usage.PromptTokens),
|
||||||
C: float64(usage.CompletionTokens),
|
C: float64(usage.CompletionTokens),
|
||||||
CR: float64(usage.PromptTokensDetails.CachedTokens),
|
CR: float64(usage.PromptTokensDetails.CachedTokens),
|
||||||
CC: float64(usage.PromptTokensDetails.CachedCreationTokens - usage.ClaudeCacheCreation1hTokens),
|
CC: float64(usage.PromptTokensDetails.CachedCreationTokens - usage.ClaudeCacheCreation1hTokens),
|
||||||
CC1h: float64(usage.ClaudeCacheCreation1hTokens),
|
CC1h: float64(usage.ClaudeCacheCreation1hTokens),
|
||||||
}); ok {
|
Img: float64(usage.PromptTokensDetails.ImageTokens),
|
||||||
postConsumeQuotaTiered(ctx, relayInfo, usage, tieredQuota, tieredResult, extraContent...)
|
AI: float64(usage.PromptTokensDetails.AudioTokens),
|
||||||
return
|
AO: float64(usage.CompletionTokenDetails.AudioTokens),
|
||||||
|
})
|
||||||
|
if tieredOk {
|
||||||
|
tieredResult = tieredRes
|
||||||
}
|
}
|
||||||
|
|
||||||
adminRejectReason := common.GetContextKeyString(ctx, constant.ContextKeyAdminRejectReason)
|
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())
|
quota := int(quotaCalculateDecimal.Round(0).IntPart())
|
||||||
|
if tieredOk {
|
||||||
|
quota = tieredQuota
|
||||||
|
}
|
||||||
totalTokens := promptTokens + completionTokens
|
totalTokens := promptTokens + completionTokens
|
||||||
|
|
||||||
//var logContent string
|
|
||||||
|
|
||||||
// record all the consume log even if quota is 0
|
// record all the consume log even if quota is 0
|
||||||
if totalTokens == 0 {
|
if totalTokens == 0 {
|
||||||
// in this case, must be some error happened
|
// 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"] = true
|
||||||
other["image_generation_call_price"] = imageGenerationCallPrice
|
other["image_generation_call_price"] = imageGenerationCallPrice
|
||||||
}
|
}
|
||||||
|
if tieredResult != nil {
|
||||||
|
service.InjectTieredBillingInfo(other, relayInfo, tieredResult)
|
||||||
|
}
|
||||||
model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{
|
model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{
|
||||||
ChannelId: relayInfo.ChannelId,
|
ChannelId: relayInfo.ChannelId,
|
||||||
PromptTokens: promptTokens,
|
PromptTokens: promptTokens,
|
||||||
@ -519,51 +527,3 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
|
|||||||
Other: other,
|
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
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/base64"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/QuantumNous/new-api/common"
|
"github.com/QuantumNous/new-api/common"
|
||||||
@ -216,41 +217,17 @@ func GenerateMjOtherInfo(relayInfo *relaycommon.RelayInfo, priceData types.Price
|
|||||||
return other
|
return other
|
||||||
}
|
}
|
||||||
|
|
||||||
func GenerateTieredOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, result *billingexpr.TieredResult) map[string]interface{} {
|
// InjectTieredBillingInfo overlays tiered billing fields onto an existing
|
||||||
other := make(map[string]interface{})
|
// module-specific other map. Call this after GenerateTextOtherInfo /
|
||||||
other["billing_mode"] = "tiered_expr"
|
// GenerateClaudeOtherInfo / etc. when the request used tiered_expr billing.
|
||||||
|
func InjectTieredBillingInfo(other map[string]interface{}, relayInfo *relaycommon.RelayInfo, result *billingexpr.TieredResult) {
|
||||||
snap := relayInfo.TieredBillingSnapshot
|
snap := relayInfo.TieredBillingSnapshot
|
||||||
if snap != nil {
|
if snap == nil {
|
||||||
other["group_ratio"] = snap.GroupRatio
|
return
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
other["billing_mode"] = "tiered_expr"
|
||||||
|
other["expr_b64"] = base64.StdEncoding.EncodeToString([]byte(snap.ExprString))
|
||||||
if result != nil {
|
if result != nil {
|
||||||
other["actual_quota_before_group"] = result.ActualQuotaBeforeGroup
|
|
||||||
other["actual_quota_after_group"] = result.ActualQuotaAfterGroup
|
|
||||||
other["matched_tier"] = result.MatchedTier
|
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,
|
func PostWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, modelName string,
|
||||||
usage *dto.RealtimeUsage, extraContent string) {
|
usage *dto.RealtimeUsage, extraContent string) {
|
||||||
|
|
||||||
// Tiered billing early return
|
var tieredResult *billingexpr.TieredResult
|
||||||
if ok, tieredQuota, tieredResult := TryTieredSettle(relayInfo, billingexpr.TokenParams{
|
tieredOk, tieredQuota, tieredRes := TryTieredSettle(relayInfo, billingexpr.TokenParams{
|
||||||
P: float64(usage.InputTokens),
|
P: float64(usage.InputTokens),
|
||||||
C: float64(usage.OutputTokens),
|
C: float64(usage.OutputTokens),
|
||||||
}); ok {
|
})
|
||||||
postConsumeQuotaTieredService(ctx, relayInfo, modelName, usage.InputTokens, usage.OutputTokens, usage.TotalTokens, tieredQuota, tieredResult, extraContent)
|
if tieredOk {
|
||||||
return
|
tieredResult = tieredRes
|
||||||
}
|
}
|
||||||
|
|
||||||
useTimeSeconds := time.Now().Unix() - relayInfo.StartTime.Unix()
|
useTimeSeconds := time.Now().Unix() - relayInfo.StartTime.Unix()
|
||||||
@ -200,6 +200,9 @@ func PostWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, mod
|
|||||||
}
|
}
|
||||||
|
|
||||||
quota := calculateAudioQuota(quotaInfo)
|
quota := calculateAudioQuota(quotaInfo)
|
||||||
|
if tieredOk {
|
||||||
|
quota = tieredQuota
|
||||||
|
}
|
||||||
|
|
||||||
totalTokens := usage.TotalTokens
|
totalTokens := usage.TotalTokens
|
||||||
var logContent string
|
var logContent string
|
||||||
@ -229,6 +232,9 @@ func PostWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, mod
|
|||||||
}
|
}
|
||||||
other := GenerateWssOtherInfo(ctx, relayInfo, usage, modelRatio, groupRatio,
|
other := GenerateWssOtherInfo(ctx, relayInfo, usage, modelRatio, groupRatio,
|
||||||
completionRatio.InexactFloat64(), audioRatio.InexactFloat64(), audioCompletionRatio.InexactFloat64(), modelPrice, relayInfo.PriceData.GroupRatioInfo.GroupSpecialRatio)
|
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{
|
model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{
|
||||||
ChannelId: relayInfo.ChannelId,
|
ChannelId: relayInfo.ChannelId,
|
||||||
PromptTokens: usage.InputTokens,
|
PromptTokens: usage.InputTokens,
|
||||||
@ -250,16 +256,16 @@ func PostClaudeConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
|
|||||||
ObserveChannelAffinityUsageCacheByRelayFormat(ctx, usage, relayInfo.GetFinalRequestRelayFormat())
|
ObserveChannelAffinityUsageCacheByRelayFormat(ctx, usage, relayInfo.GetFinalRequestRelayFormat())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tiered billing early return
|
var tieredResult *billingexpr.TieredResult
|
||||||
if ok, tieredQuota, tieredResult := TryTieredSettle(relayInfo, billingexpr.TokenParams{
|
tieredOk, tieredQuota, tieredRes := TryTieredSettle(relayInfo, billingexpr.TokenParams{
|
||||||
P: float64(usage.PromptTokens),
|
P: float64(usage.PromptTokens),
|
||||||
C: float64(usage.CompletionTokens),
|
C: float64(usage.CompletionTokens),
|
||||||
CR: float64(usage.PromptTokensDetails.CachedTokens),
|
CR: float64(usage.PromptTokensDetails.CachedTokens),
|
||||||
CC: float64(usage.PromptTokensDetails.CachedCreationTokens - usage.ClaudeCacheCreation1hTokens),
|
CC: float64(usage.PromptTokensDetails.CachedCreationTokens - usage.ClaudeCacheCreation1hTokens),
|
||||||
CC1h: float64(usage.ClaudeCacheCreation1hTokens),
|
CC1h: float64(usage.ClaudeCacheCreation1hTokens),
|
||||||
}); ok {
|
})
|
||||||
postConsumeQuotaTieredService(ctx, relayInfo, relayInfo.OriginModelName, usage.PromptTokens, usage.CompletionTokens, usage.PromptTokens+usage.CompletionTokens, tieredQuota, tieredResult, "")
|
if tieredOk {
|
||||||
return
|
tieredResult = tieredRes
|
||||||
}
|
}
|
||||||
|
|
||||||
useTimeSeconds := time.Now().Unix() - relayInfo.StartTime.Unix()
|
useTimeSeconds := time.Now().Unix() - relayInfo.StartTime.Unix()
|
||||||
@ -315,6 +321,9 @@ func PostClaudeConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
|
|||||||
}
|
}
|
||||||
|
|
||||||
quota := int(calculateQuota)
|
quota := int(calculateQuota)
|
||||||
|
if tieredOk {
|
||||||
|
quota = tieredQuota
|
||||||
|
}
|
||||||
|
|
||||||
totalTokens := promptTokens + completionTokens
|
totalTokens := promptTokens + completionTokens
|
||||||
|
|
||||||
@ -342,6 +351,9 @@ func PostClaudeConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
|
|||||||
cacheCreationTokens5m, cacheCreationRatio5m,
|
cacheCreationTokens5m, cacheCreationRatio5m,
|
||||||
cacheCreationTokens1h, cacheCreationRatio1h,
|
cacheCreationTokens1h, cacheCreationRatio1h,
|
||||||
modelPrice, relayInfo.PriceData.GroupRatioInfo.GroupSpecialRatio)
|
modelPrice, relayInfo.PriceData.GroupRatioInfo.GroupSpecialRatio)
|
||||||
|
if tieredResult != nil {
|
||||||
|
InjectTieredBillingInfo(other, relayInfo, tieredResult)
|
||||||
|
}
|
||||||
model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{
|
model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{
|
||||||
ChannelId: relayInfo.ChannelId,
|
ChannelId: relayInfo.ChannelId,
|
||||||
PromptTokens: promptTokens,
|
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) {
|
func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, extraContent string) {
|
||||||
|
|
||||||
// Tiered billing early return
|
var tieredResult *billingexpr.TieredResult
|
||||||
if ok, tieredQuota, tieredResult := TryTieredSettle(relayInfo, billingexpr.TokenParams{
|
tieredOk, tieredQuota, tieredRes := TryTieredSettle(relayInfo, billingexpr.TokenParams{
|
||||||
P: float64(usage.PromptTokens),
|
P: float64(usage.PromptTokens),
|
||||||
C: float64(usage.CompletionTokens),
|
C: float64(usage.CompletionTokens),
|
||||||
CR: float64(usage.PromptTokensDetails.CachedTokens),
|
CR: float64(usage.PromptTokensDetails.CachedTokens),
|
||||||
}); ok {
|
AI: float64(usage.PromptTokensDetails.AudioTokens),
|
||||||
postConsumeQuotaTieredService(ctx, relayInfo, relayInfo.OriginModelName, usage.PromptTokens, usage.CompletionTokens, usage.TotalTokens, tieredQuota, tieredResult, extraContent)
|
AO: float64(usage.CompletionTokenDetails.AudioTokens),
|
||||||
return
|
})
|
||||||
|
if tieredOk {
|
||||||
|
tieredResult = tieredRes
|
||||||
}
|
}
|
||||||
|
|
||||||
useTimeSeconds := time.Now().Unix() - relayInfo.StartTime.Unix()
|
useTimeSeconds := time.Now().Unix() - relayInfo.StartTime.Unix()
|
||||||
@ -425,6 +439,9 @@ func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, u
|
|||||||
}
|
}
|
||||||
|
|
||||||
quota := calculateAudioQuota(quotaInfo)
|
quota := calculateAudioQuota(quotaInfo)
|
||||||
|
if tieredOk {
|
||||||
|
quota = tieredQuota
|
||||||
|
}
|
||||||
|
|
||||||
totalTokens := usage.TotalTokens
|
totalTokens := usage.TotalTokens
|
||||||
var logContent string
|
var logContent string
|
||||||
@ -458,6 +475,9 @@ func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, u
|
|||||||
}
|
}
|
||||||
other := GenerateAudioOtherInfo(ctx, relayInfo, usage, modelRatio, groupRatio,
|
other := GenerateAudioOtherInfo(ctx, relayInfo, usage, modelRatio, groupRatio,
|
||||||
completionRatio.InexactFloat64(), audioRatio.InexactFloat64(), audioCompletionRatio.InexactFloat64(), modelPrice, relayInfo.PriceData.GroupRatioInfo.GroupSpecialRatio)
|
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{
|
model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{
|
||||||
ChannelId: relayInfo.ChannelId,
|
ChannelId: relayInfo.ChannelId,
|
||||||
PromptTokens: usage.PromptTokens,
|
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 ModelBasicInfo from './components/ModelBasicInfo';
|
||||||
import ModelEndpoints from './components/ModelEndpoints';
|
import ModelEndpoints from './components/ModelEndpoints';
|
||||||
import ModelPricingTable from './components/ModelPricingTable';
|
import ModelPricingTable from './components/ModelPricingTable';
|
||||||
|
import DynamicPricingBreakdown from './components/DynamicPricingBreakdown';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
@ -89,6 +90,12 @@ const ModelDetailSideSheet = ({
|
|||||||
endpointMap={endpointMap}
|
endpointMap={endpointMap}
|
||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
|
{modelData.billing_mode === 'tiered_expr' && modelData.billing_expr && (
|
||||||
|
<DynamicPricingBreakdown
|
||||||
|
billingExpr={modelData.billing_expr}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<ModelPricingTable
|
<ModelPricingTable
|
||||||
modelData={modelData}
|
modelData={modelData}
|
||||||
groupRatio={groupRatio}
|
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,
|
group: group,
|
||||||
ratio: groupRatioValue,
|
ratio: groupRatioValue,
|
||||||
billingType:
|
billingType:
|
||||||
modelData?.quota_type === 0
|
modelData?.billing_mode === 'tiered_expr'
|
||||||
? t('按量计费')
|
? t('动态计费')
|
||||||
: modelData?.quota_type === 1
|
: modelData?.quota_type === 0
|
||||||
? t('按次计费')
|
? t('按量计费')
|
||||||
: '-',
|
: modelData?.quota_type === 1
|
||||||
|
? t('按次计费')
|
||||||
|
: '-',
|
||||||
priceItems: getModelPriceItems(priceData, t, siteDisplayType),
|
priceItems: getModelPriceItems(priceData, t, siteDisplayType),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@ -94,20 +96,21 @@ const ModelPricingTable = ({
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// 如果显示倍率,添加倍率列
|
const isDynamic = modelData?.billing_mode === 'tiered_expr';
|
||||||
if (showRatio) {
|
|
||||||
|
// 动态计费时始终显示倍率列,否则根据设置
|
||||||
|
if (showRatio || isDynamic) {
|
||||||
columns.push({
|
columns.push({
|
||||||
title: t('倍率'),
|
title: t('分组倍率'),
|
||||||
dataIndex: 'ratio',
|
dataIndex: 'ratio',
|
||||||
render: (text) => (
|
render: (text) => (
|
||||||
<Tag color='white' size='small' shape='circle'>
|
<Tag color='blue' size='small' shape='circle'>
|
||||||
{text}x
|
{text}x
|
||||||
</Tag>
|
</Tag>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加计费类型列
|
|
||||||
columns.push({
|
columns.push({
|
||||||
title: t('计费类型'),
|
title: t('计费类型'),
|
||||||
dataIndex: 'billingType',
|
dataIndex: 'billingType',
|
||||||
@ -115,6 +118,7 @@ const ModelPricingTable = ({
|
|||||||
let color = 'white';
|
let color = 'white';
|
||||||
if (text === t('按量计费')) color = 'violet';
|
if (text === t('按量计费')) color = 'violet';
|
||||||
else if (text === t('按次计费')) color = 'teal';
|
else if (text === t('按次计费')) color = 'teal';
|
||||||
|
else if (text === t('动态计费')) color = 'amber';
|
||||||
return (
|
return (
|
||||||
<Tag color={color} size='small' shape='circle'>
|
<Tag color={color} size='small' shape='circle'>
|
||||||
{text || '-'}
|
{text || '-'}
|
||||||
@ -126,18 +130,27 @@ const ModelPricingTable = ({
|
|||||||
columns.push({
|
columns.push({
|
||||||
title: siteDisplayType === 'TOKENS' ? t('计费摘要') : t('价格摘要'),
|
title: siteDisplayType === 'TOKENS' ? t('计费摘要') : t('价格摘要'),
|
||||||
dataIndex: 'priceItems',
|
dataIndex: 'priceItems',
|
||||||
render: (items) => (
|
render: (items) => {
|
||||||
<div className='space-y-1'>
|
if (items.length === 1 && items[0].isDynamic) {
|
||||||
{items.map((item) => (
|
return (
|
||||||
<div key={item.key}>
|
<Text type='tertiary' size='small'>
|
||||||
<div className='font-semibold text-orange-600'>
|
{t('见上方动态计费详情')}
|
||||||
{item.label} {item.value}
|
</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>
|
||||||
<div className='text-xs text-gray-500'>{item.suffix}</div>
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
</div>
|
},
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -38,6 +38,7 @@ import {
|
|||||||
stringToColor,
|
stringToColor,
|
||||||
calculateModelPrice,
|
calculateModelPrice,
|
||||||
formatPriceInfo,
|
formatPriceInfo,
|
||||||
|
formatDynamicPriceSummary,
|
||||||
getLobeHubIcon,
|
getLobeHubIcon,
|
||||||
} from '../../../../../helpers';
|
} from '../../../../../helpers';
|
||||||
import PricingCardSkeleton from './PricingCardSkeleton';
|
import PricingCardSkeleton from './PricingCardSkeleton';
|
||||||
@ -267,7 +268,11 @@ const PricingCardView = ({
|
|||||||
{model.model_name}
|
{model.model_name}
|
||||||
</h3>
|
</h3>
|
||||||
<div className='flex flex-col gap-1 text-xs mt-1'>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -33,6 +33,7 @@ import {
|
|||||||
getLogOther,
|
getLogOther,
|
||||||
renderModelTag,
|
renderModelTag,
|
||||||
renderModelPriceSimple,
|
renderModelPriceSimple,
|
||||||
|
renderTieredModelPriceSimple,
|
||||||
} from '../../../helpers';
|
} from '../../../helpers';
|
||||||
import { IconHelpCircle } from '@douyinfe/semi-icons';
|
import { IconHelpCircle } from '@douyinfe/semi-icons';
|
||||||
import { Route, Sparkles } from 'lucide-react';
|
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) {
|
function getUsageLogDetailSummary(record, text, billingDisplayMode, t) {
|
||||||
const other = getLogOther(record.other);
|
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') {
|
if (other?.billing_mode === 'tiered_expr') {
|
||||||
return buildTieredBillingSegments(other, t);
|
return { segments: renderTieredModelPriceSimple(summaryOpts) };
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
segments: other?.claude
|
segments: other?.claude
|
||||||
? renderModelPriceSimple(
|
? renderModelPriceSimple({ ...summaryOpts, provider: 'claude' })
|
||||||
other.model_ratio,
|
: renderModelPriceSimple({ ...summaryOpts, provider: 'openai' }),
|
||||||
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',
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
406
web/src/helpers/render.jsx
vendored
406
web/src/helpers/render.jsx
vendored
@ -1620,37 +1620,38 @@ function renderPriceSimpleCore({
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderModelPrice(
|
export function renderModelPrice(opts) {
|
||||||
inputTokens,
|
const {
|
||||||
completionTokens,
|
prompt_tokens: inputTokens = 0,
|
||||||
modelRatio,
|
completion_tokens: completionTokens = 0,
|
||||||
modelPrice = -1,
|
model_ratio: modelRatio = 0,
|
||||||
completionRatio,
|
model_price: modelPrice = -1,
|
||||||
groupRatio,
|
completion_ratio: completionRatio,
|
||||||
user_group_ratio,
|
group_ratio: _groupRatio,
|
||||||
cacheTokens = 0,
|
user_group_ratio,
|
||||||
cacheRatio = 1.0,
|
cache_tokens: cacheTokens = 0,
|
||||||
image = false,
|
cache_ratio: cacheRatio = 1.0,
|
||||||
imageRatio = 1.0,
|
image = false,
|
||||||
imageOutputTokens = 0,
|
image_ratio: imageRatio = 1.0,
|
||||||
webSearch = false,
|
image_output: imageOutputTokens = 0,
|
||||||
webSearchCallCount = 0,
|
web_search: webSearch = false,
|
||||||
webSearchPrice = 0,
|
web_search_call_count: webSearchCallCount = 0,
|
||||||
fileSearch = false,
|
web_search_price: webSearchPrice = 0,
|
||||||
fileSearchCallCount = 0,
|
file_search: fileSearch = false,
|
||||||
fileSearchPrice = 0,
|
file_search_call_count: fileSearchCallCount = 0,
|
||||||
audioInputSeperatePrice = false,
|
file_search_price: fileSearchPrice = 0,
|
||||||
audioInputTokens = 0,
|
audio_input_seperate_price: audioInputSeperatePrice = false,
|
||||||
audioInputPrice = 0,
|
audio_input_token_count: audioInputTokens = 0,
|
||||||
imageGenerationCall = false,
|
audio_input_price: audioInputPrice = 0,
|
||||||
imageGenerationCallPrice = 0,
|
image_generation_call: imageGenerationCall = false,
|
||||||
displayMode = 'price',
|
image_generation_call_price: imageGenerationCallPrice = 0,
|
||||||
) {
|
displayMode = 'price',
|
||||||
|
} = opts;
|
||||||
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(
|
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(
|
||||||
groupRatio,
|
_groupRatio,
|
||||||
user_group_ratio,
|
user_group_ratio,
|
||||||
);
|
);
|
||||||
groupRatio = effectiveGroupRatio;
|
let groupRatio = effectiveGroupRatio;
|
||||||
|
|
||||||
const { symbol, rate } = getCurrencyConfig();
|
const { symbol, rate } = getCurrencyConfig();
|
||||||
|
|
||||||
@ -2078,21 +2079,22 @@ export function renderModelPrice(
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderLogContent(
|
export function renderLogContent(opts) {
|
||||||
modelRatio,
|
const {
|
||||||
completionRatio,
|
model_ratio: modelRatio,
|
||||||
modelPrice = -1,
|
completion_ratio: completionRatio,
|
||||||
groupRatio,
|
model_price: modelPrice = -1,
|
||||||
user_group_ratio,
|
group_ratio: groupRatio,
|
||||||
cacheRatio = 1.0,
|
user_group_ratio,
|
||||||
image = false,
|
cache_ratio: cacheRatio = 1.0,
|
||||||
imageRatio = 1.0,
|
image = false,
|
||||||
webSearch = false,
|
image_ratio: imageRatio = 1.0,
|
||||||
webSearchCallCount = 0,
|
web_search: webSearch = false,
|
||||||
fileSearch = false,
|
web_search_call_count: webSearchCallCount = 0,
|
||||||
fileSearchCallCount = 0,
|
file_search: fileSearch = false,
|
||||||
displayMode = 'price',
|
file_search_call_count: fileSearchCallCount = 0,
|
||||||
) {
|
displayMode = 'price',
|
||||||
|
} = opts;
|
||||||
const {
|
const {
|
||||||
ratio,
|
ratio,
|
||||||
label: ratioLabel,
|
label: ratioLabel,
|
||||||
@ -2208,26 +2210,193 @@ export function renderLogContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderModelPriceSimple(
|
function parseTiersFromExpr(exprStr) {
|
||||||
modelRatio,
|
if (!exprStr) return [];
|
||||||
modelPrice = -1,
|
try {
|
||||||
groupRatio,
|
const cacheVars = ['cr', 'cc', 'cc1h'];
|
||||||
user_group_ratio,
|
const optCache = cacheVars.map((v) => `(?:\\s*\\+\\s*${v}\\s*\\*\\s*([\\d.eE+-]+))?`).join('');
|
||||||
cacheTokens = 0,
|
const bodyPat = `p\\s*\\*\\s*([\\d.eE+-]+)\\s*\\+\\s*c\\s*\\*\\s*([\\d.eE+-]+)${optCache}`;
|
||||||
cacheRatio = 1.0,
|
const tierRe = new RegExp(`tier\\("([^"]*)",\\s*${bodyPat}\\)`, 'g');
|
||||||
cacheCreationTokens = 0,
|
const tiers = [];
|
||||||
cacheCreationRatio = 1.0,
|
let m;
|
||||||
cacheCreationTokens5m = 0,
|
while ((m = tierRe.exec(exprStr)) !== null) {
|
||||||
cacheCreationRatio5m = 1.0,
|
tiers.push({
|
||||||
cacheCreationTokens1h = 0,
|
label: m[1],
|
||||||
cacheCreationRatio1h = 1.0,
|
inputPrice: Number(m[2]) * 2,
|
||||||
image = false,
|
outputPrice: Number(m[3]) * 2,
|
||||||
imageRatio = 1.0,
|
cacheReadPrice: m[4] ? Number(m[4]) * 2 : 0,
|
||||||
isSystemPromptOverride = false,
|
cacheCreatePrice: m[5] ? Number(m[5]) * 2 : 0,
|
||||||
provider = 'openai',
|
cacheCreate1hPrice: m[6] ? Number(m[6]) * 2 : 0,
|
||||||
displayMode = 'price',
|
});
|
||||||
outputMode = 'text',
|
}
|
||||||
) {
|
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({
|
return renderPriceSimpleCore({
|
||||||
modelRatio,
|
modelRatio,
|
||||||
modelPrice,
|
modelPrice,
|
||||||
@ -2249,27 +2418,28 @@ export function renderModelPriceSimple(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderAudioModelPrice(
|
export function renderAudioModelPrice(opts) {
|
||||||
inputTokens,
|
const {
|
||||||
completionTokens,
|
prompt_tokens: inputTokens = 0,
|
||||||
modelRatio,
|
completion_tokens: completionTokens = 0,
|
||||||
modelPrice = -1,
|
model_ratio: modelRatio = 0,
|
||||||
completionRatio,
|
model_price: modelPrice = -1,
|
||||||
audioInputTokens,
|
completion_ratio: completionRatio,
|
||||||
audioCompletionTokens,
|
audio_input: audioInputTokens = 0,
|
||||||
audioRatio,
|
audio_output: audioCompletionTokens = 0,
|
||||||
audioCompletionRatio,
|
audio_ratio: audioRatio,
|
||||||
groupRatio,
|
audio_completion_ratio: audioCompletionRatio,
|
||||||
user_group_ratio,
|
group_ratio: _groupRatio,
|
||||||
cacheTokens = 0,
|
user_group_ratio,
|
||||||
cacheRatio = 1.0,
|
cache_tokens: cacheTokens = 0,
|
||||||
displayMode = 'price',
|
cache_ratio: cacheRatio = 1.0,
|
||||||
) {
|
displayMode = 'price',
|
||||||
|
} = opts;
|
||||||
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(
|
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(
|
||||||
groupRatio,
|
_groupRatio,
|
||||||
user_group_ratio,
|
user_group_ratio,
|
||||||
);
|
);
|
||||||
groupRatio = effectiveGroupRatio;
|
let groupRatio = effectiveGroupRatio;
|
||||||
|
|
||||||
// 获取货币配置
|
// 获取货币配置
|
||||||
const { symbol, rate } = getCurrencyConfig();
|
const { symbol, rate } = getCurrencyConfig();
|
||||||
@ -2535,29 +2705,30 @@ export function renderQuotaWithPrompt(quota, digits) {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderClaudeModelPrice(
|
export function renderClaudeModelPrice(opts) {
|
||||||
inputTokens,
|
const {
|
||||||
completionTokens,
|
prompt_tokens: inputTokens = 0,
|
||||||
modelRatio,
|
completion_tokens: completionTokens = 0,
|
||||||
modelPrice = -1,
|
model_ratio: modelRatio = 0,
|
||||||
completionRatio,
|
model_price: modelPrice = -1,
|
||||||
groupRatio,
|
completion_ratio: completionRatio,
|
||||||
user_group_ratio,
|
group_ratio: _groupRatio,
|
||||||
cacheTokens = 0,
|
user_group_ratio,
|
||||||
cacheRatio = 1.0,
|
cache_tokens: cacheTokens = 0,
|
||||||
cacheCreationTokens = 0,
|
cache_ratio: cacheRatio = 1.0,
|
||||||
cacheCreationRatio = 1.0,
|
cache_creation_tokens: cacheCreationTokens = 0,
|
||||||
cacheCreationTokens5m = 0,
|
cache_creation_ratio: cacheCreationRatio = 1.0,
|
||||||
cacheCreationRatio5m = 1.0,
|
cache_creation_tokens_5m: cacheCreationTokens5m = 0,
|
||||||
cacheCreationTokens1h = 0,
|
cache_creation_ratio_5m: cacheCreationRatio5m = 1.0,
|
||||||
cacheCreationRatio1h = 1.0,
|
cache_creation_tokens_1h: cacheCreationTokens1h = 0,
|
||||||
displayMode = 'price',
|
cache_creation_ratio_1h: cacheCreationRatio1h = 1.0,
|
||||||
) {
|
displayMode = 'price',
|
||||||
|
} = opts;
|
||||||
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(
|
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(
|
||||||
groupRatio,
|
_groupRatio,
|
||||||
user_group_ratio,
|
user_group_ratio,
|
||||||
);
|
);
|
||||||
groupRatio = effectiveGroupRatio;
|
let groupRatio = effectiveGroupRatio;
|
||||||
|
|
||||||
// 获取货币配置
|
// 获取货币配置
|
||||||
const { symbol, rate } = getCurrencyConfig();
|
const { symbol, rate } = getCurrencyConfig();
|
||||||
@ -2944,25 +3115,26 @@ export function renderClaudeModelPrice(
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderClaudeLogContent(
|
export function renderClaudeLogContent(opts) {
|
||||||
modelRatio,
|
const {
|
||||||
completionRatio,
|
model_ratio: modelRatio,
|
||||||
modelPrice = -1,
|
completion_ratio: completionRatio,
|
||||||
groupRatio,
|
model_price: modelPrice = -1,
|
||||||
user_group_ratio,
|
group_ratio: _groupRatio,
|
||||||
cacheRatio = 1.0,
|
user_group_ratio,
|
||||||
cacheCreationRatio = 1.0,
|
cache_ratio: cacheRatio = 1.0,
|
||||||
cacheCreationTokens5m = 0,
|
cache_creation_ratio: cacheCreationRatio = 1.0,
|
||||||
cacheCreationRatio5m = 1.0,
|
cache_creation_tokens_5m: cacheCreationTokens5m = 0,
|
||||||
cacheCreationTokens1h = 0,
|
cache_creation_ratio_5m: cacheCreationRatio5m = 1.0,
|
||||||
cacheCreationRatio1h = 1.0,
|
cache_creation_tokens_1h: cacheCreationTokens1h = 0,
|
||||||
displayMode = 'price',
|
cache_creation_ratio_1h: cacheCreationRatio1h = 1.0,
|
||||||
) {
|
displayMode = 'price',
|
||||||
|
} = opts;
|
||||||
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(
|
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(
|
||||||
groupRatio,
|
_groupRatio,
|
||||||
user_group_ratio,
|
user_group_ratio,
|
||||||
);
|
);
|
||||||
groupRatio = effectiveGroupRatio;
|
let groupRatio = effectiveGroupRatio;
|
||||||
|
|
||||||
// 获取货币配置
|
// 获取货币配置
|
||||||
const { symbol, rate } = getCurrencyConfig();
|
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) {
|
if (record.quota_type === 0) {
|
||||||
// 按量计费
|
// 按量计费
|
||||||
const isTokensDisplay = quotaDisplayType === 'TOKENS';
|
const isTokensDisplay = quotaDisplayType === 'TOKENS';
|
||||||
@ -766,6 +776,18 @@ export const getModelPriceItems = (
|
|||||||
t,
|
t,
|
||||||
quotaDisplayType = 'USD',
|
quotaDisplayType = 'USD',
|
||||||
) => {
|
) => {
|
||||||
|
if (priceData.isDynamicPricing) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'dynamic',
|
||||||
|
label: t('动态计费'),
|
||||||
|
value: '',
|
||||||
|
suffix: '',
|
||||||
|
isDynamic: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
if (priceData.isPerToken) {
|
if (priceData.isPerToken) {
|
||||||
if (quotaDisplayType === 'TOKENS' || priceData.isTokensDisplay) {
|
if (quotaDisplayType === 'TOKENS' || priceData.isTokensDisplay) {
|
||||||
return [
|
return [
|
||||||
@ -874,6 +896,83 @@ export const getModelPriceItems = (
|
|||||||
].filter((item) => item.value !== null && item.value !== undefined && item.value !== '');
|
].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') => {
|
export const formatPriceInfo = (priceData, t, quotaDisplayType = 'USD') => {
|
||||||
const items = getModelPriceItems(priceData, t, quotaDisplayType);
|
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,
|
renderAudioModelPrice,
|
||||||
renderClaudeModelPrice,
|
renderClaudeModelPrice,
|
||||||
renderModelPrice,
|
renderModelPrice,
|
||||||
|
renderTieredModelPrice,
|
||||||
} from '../../helpers';
|
} from '../../helpers';
|
||||||
import { ITEMS_PER_PAGE } from '../../constants';
|
import { ITEMS_PER_PAGE } from '../../constants';
|
||||||
import { useTableCompactMode } from '../common/useTableCompactMode';
|
import { useTableCompactMode } from '../common/useTableCompactMode';
|
||||||
@ -407,43 +408,14 @@ export const useLogsData = () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (logs[i].type === 2) {
|
if (logs[i].type === 2) {
|
||||||
expandDataLocal.push({
|
if (other?.billing_mode !== 'tiered_expr') {
|
||||||
key: t('日志详情'),
|
expandDataLocal.push({
|
||||||
value: other?.claude
|
key: t('日志详情'),
|
||||||
? renderClaudeLogContent(
|
value: other?.claude
|
||||||
other?.model_ratio,
|
? renderClaudeLogContent({ ...other, displayMode: billingDisplayMode })
|
||||||
other.completion_ratio,
|
: renderLogContent({ ...other, displayMode: billingDisplayMode }),
|
||||||
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 (logs[i]?.content) {
|
if (logs[i]?.content) {
|
||||||
expandDataLocal.push({
|
expandDataLocal.push({
|
||||||
key: t('其他详情'),
|
key: t('其他详情'),
|
||||||
@ -479,74 +451,19 @@ export const useLogsData = () => {
|
|||||||
Boolean(other?.violation_fee_marker);
|
Boolean(other?.violation_fee_marker);
|
||||||
|
|
||||||
let content = '';
|
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) {
|
if (other?.ws || other?.audio) {
|
||||||
content = renderAudioModelPrice(
|
content = renderAudioModelPrice(logOpts);
|
||||||
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,
|
|
||||||
);
|
|
||||||
} else if (other?.claude) {
|
} else if (other?.claude) {
|
||||||
content = renderClaudeModelPrice(
|
content = renderClaudeModelPrice(logOpts);
|
||||||
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,
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
content = renderModelPrice(
|
content = renderModelPrice(logOpts);
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
expandDataLocal.push({
|
expandDataLocal.push({
|
||||||
key: t('计费过程'),
|
key: t('计费过程'),
|
||||||
@ -559,65 +476,15 @@ export const useLogsData = () => {
|
|||||||
value: other.reasoning_effort,
|
value: other.reasoning_effort,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (other?.billing_mode === 'tiered_expr') {
|
if (other?.billing_mode === 'tiered_expr' && other?.expr_b64) {
|
||||||
expandDataLocal.push({
|
expandDataLocal.push({
|
||||||
key: t('计费方式'),
|
key: t('计费过程'),
|
||||||
value: 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) {
|
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",
|
"新年促销": "New Year promo",
|
||||||
"第 {{n}} 组": "Group {{n}}",
|
"第 {{n}} 组": "Group {{n}}",
|
||||||
"0=周日 1=周一 2=周二 3=周三 4=周四 5=周五 6=周六": "0=Sun 1=Mon 2=Tue 3=Wed 4=Thu 5=Fri 6=Sat",
|
"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}} 组",
|
"第 {{n}} 组": "第 {{n}} 组",
|
||||||
"0=周日 1=周一 2=周二 3=周三 4=周四 5=周五 6=周六": "0=周日 1=周一 2=周二 3=周三 4=周四 5=周五 6=周六",
|
"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);
|
height: calc(100vh - 77px);
|
||||||
max-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,
|
gap: 16,
|
||||||
gridTemplateColumns: isMobile
|
gridTemplateColumns: isMobile
|
||||||
? 'minmax(0, 1fr)'
|
? 'minmax(0, 1fr)'
|
||||||
: 'minmax(360px, 1.1fr) minmax(420px, 1fr)',
|
: 'minmax(300px, 0.8fr) minmax(480px, 1.2fr)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Card
|
<Card
|
||||||
|
|||||||
@ -96,11 +96,14 @@ function buildConditionStr(conditions) {
|
|||||||
.join(' && ');
|
.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 = [
|
const CACHE_VAR_MAP = [
|
||||||
{ field: 'cache_read_unit_cost', exprVar: 'cr' },
|
{ field: 'cache_read_unit_cost', exprVar: 'cr' },
|
||||||
{ field: 'cache_create_unit_cost', exprVar: 'cc' },
|
{ field: 'cache_create_unit_cost', exprVar: 'cc' },
|
||||||
{ field: 'cache_create_1h_unit_cost', exprVar: 'cc1h' },
|
{ 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) {
|
function getTierCacheMode(tier) {
|
||||||
@ -130,7 +133,7 @@ function createDefaultVisualConfig() {
|
|||||||
conditions: [],
|
conditions: [],
|
||||||
input_unit_cost: 0,
|
input_unit_cost: 0,
|
||||||
output_unit_cost: 0,
|
output_unit_cost: 0,
|
||||||
label: '默认',
|
label: 'base',
|
||||||
cache_mode: CACHE_MODE_GENERIC,
|
cache_mode: CACHE_MODE_GENERIC,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
@ -270,64 +273,58 @@ function tryParseVisualConfig(exprStr) {
|
|||||||
function ConditionRow({ cond, onChange, onRemove, t }) {
|
function ConditionRow({ cond, onChange, onRemove, t }) {
|
||||||
const hint = formatTokenHint(cond.value);
|
const hint = formatTokenHint(cond.value);
|
||||||
return (
|
return (
|
||||||
<div style={{ marginBottom: 6 }}>
|
<div style={{
|
||||||
<div
|
marginBottom: 6,
|
||||||
style={{
|
display: 'grid',
|
||||||
display: 'flex',
|
gridTemplateColumns: '1fr auto 1fr auto',
|
||||||
alignItems: 'center',
|
gap: '4px 6px',
|
||||||
gap: 6,
|
alignItems: 'center',
|
||||||
}}
|
}}>
|
||||||
|
<Select
|
||||||
|
size='small'
|
||||||
|
value={cond.var || 'p'}
|
||||||
|
onChange={(val) => onChange({ ...cond, var: val })}
|
||||||
>
|
>
|
||||||
<Select
|
{VAR_OPTIONS.map((v) => (
|
||||||
size='small'
|
<Select.Option key={v.value} value={v.value}>
|
||||||
value={cond.var || 'p'}
|
{v.label}
|
||||||
onChange={(val) => onChange({ ...cond, var: val })}
|
</Select.Option>
|
||||||
style={{ width: 110 }}
|
))}
|
||||||
>
|
</Select>
|
||||||
{VAR_OPTIONS.map((v) => (
|
<Select
|
||||||
<Select.Option key={v.value} value={v.value}>
|
size='small'
|
||||||
{v.label}
|
value={cond.op || '<'}
|
||||||
</Select.Option>
|
onChange={(val) => onChange({ ...cond, op: val })}
|
||||||
))}
|
style={{ width: 70 }}
|
||||||
</Select>
|
>
|
||||||
<Select
|
{OPS.map((op) => (
|
||||||
size='small'
|
<Select.Option key={op} value={op}>
|
||||||
value={cond.op || '<'}
|
{op}
|
||||||
onChange={(val) => onChange({ ...cond, op: val })}
|
</Select.Option>
|
||||||
style={{ width: 70 }}
|
))}
|
||||||
>
|
</Select>
|
||||||
{OPS.map((op) => (
|
<InputNumber
|
||||||
<Select.Option key={op} value={op}>
|
size='small'
|
||||||
{op}
|
min={0}
|
||||||
</Select.Option>
|
value={cond.value ?? ''}
|
||||||
))}
|
onChange={(val) => onChange({ ...cond, value: val })}
|
||||||
</Select>
|
/>
|
||||||
<InputNumber
|
<Button
|
||||||
size='small'
|
icon={<IconDelete />}
|
||||||
min={0}
|
type='danger'
|
||||||
value={cond.value ?? ''}
|
theme='borderless'
|
||||||
onChange={(val) => onChange({ ...cond, value: val })}
|
size='small'
|
||||||
style={{ flex: 1, minWidth: 100 }}
|
onClick={onRemove}
|
||||||
/>
|
/>
|
||||||
<Button
|
|
||||||
icon={<IconDelete />}
|
|
||||||
type='danger'
|
|
||||||
theme='borderless'
|
|
||||||
size='small'
|
|
||||||
onClick={onRemove}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{hint ? (
|
{hint ? (
|
||||||
<Text
|
<Text
|
||||||
size='small'
|
size='small'
|
||||||
style={{
|
style={{
|
||||||
color: 'var(--semi-color-text-3)',
|
color: 'var(--semi-color-text-3)',
|
||||||
marginLeft: 186,
|
gridColumn: '3 / 4',
|
||||||
display: 'block',
|
|
||||||
marginTop: 1,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{hint}
|
= {hint}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@ -388,8 +385,8 @@ const CACHE_FIELDS_GENERIC = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
function ExtendedPriceBlock({ tier, index, onUpdate, t }) {
|
function ExtendedPriceBlock({ tier, index, onUpdate, t }) {
|
||||||
const hasAny = [...CACHE_FIELDS_TIMED].some(
|
const hasAny = [...CACHE_FIELDS_TIMED, 'image_unit_cost', 'audio_input_unit_cost', 'audio_output_unit_cost'].some(
|
||||||
(f) => Number(tier[f.field]) > 0,
|
(f) => Number(tier[typeof f === 'string' ? f : f.field]) > 0,
|
||||||
);
|
);
|
||||||
const [expanded, setExpanded] = useState(hasAny);
|
const [expanded, setExpanded] = useState(hasAny);
|
||||||
const cacheMode = getTierCacheMode(tier);
|
const cacheMode = getTierCacheMode(tier);
|
||||||
@ -461,6 +458,37 @@ function ExtendedPriceBlock({ tier, index, onUpdate, t }) {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</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>
|
</div>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
</div>
|
</div>
|
||||||
@ -730,73 +758,114 @@ function VisualEditor({ visualConfig, onChange, t }) {
|
|||||||
// Raw Expr editor with preset templates
|
// Raw Expr editor with preset templates
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const PRESETS = [
|
const PRESET_GROUPS = [
|
||||||
{
|
{
|
||||||
key: 'claude-opus',
|
group: '固定价格',
|
||||||
label: 'Claude Opus 4.6',
|
presets: [
|
||||||
expr: 'tier("default", p * 2.5 + c * 12.5 + cr * 0.25 + cc * 3.125 + cc1h * 5)',
|
{ 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-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' },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'claude-sonnet',
|
group: '阶梯计费',
|
||||||
label: 'Claude Sonnet 4.5',
|
presets: [
|
||||||
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: '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: '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' },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'flat',
|
group: '多模态',
|
||||||
label: 'Flat',
|
presets: [
|
||||||
expr: 'tier("default", p * 1 + c * 2)',
|
{ 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: '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' },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'weekend-discount',
|
group: '请求条件',
|
||||||
label: '周末8折',
|
presets: [
|
||||||
expr: 'tier("default", p * 1.5 + c * 7.5)',
|
{
|
||||||
requestRules: [
|
key: 'claude-opus-fast', label: 'Claude Opus 4.6 Fast',
|
||||||
{ conditions: [{ source: SOURCE_TIME, timeFunc: 'weekday', timezone: 'Asia/Shanghai', mode: MATCH_EQ, value: '0' }], multiplier: '0.8' },
|
expr: 'tier("base", p * 2.5 + c * 12.5 + cr * 0.25 + cc * 3.125 + cc1h * 5)',
|
||||||
{ conditions: [{ source: SOURCE_TIME, timeFunc: 'weekday', timezone: 'Asia/Shanghai', mode: MATCH_EQ, value: '6' }], multiplier: '0.8' },
|
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',
|
group: '时间促销',
|
||||||
label: '新年促销',
|
presets: [
|
||||||
expr: 'tier("default", p * 1.5 + c * 7.5)',
|
{
|
||||||
requestRules: [
|
key: 'night-discount', label: '夜间半价',
|
||||||
{ conditions: [
|
expr: 'tier("base", p * 1.5 + c * 7.5)',
|
||||||
{ source: SOURCE_TIME, timeFunc: 'month', timezone: 'Asia/Shanghai', mode: MATCH_EQ, value: '1' },
|
requestRules: [{ conditions: [{ source: SOURCE_TIME, timeFunc: 'hour', timezone: 'Asia/Shanghai', mode: MATCH_RANGE, rangeStart: '21', rangeEnd: '6' }], multiplier: '0.5' }],
|
||||||
{ source: SOURCE_TIME, timeFunc: 'day', timezone: 'Asia/Shanghai', mode: MATCH_EQ, value: '1' },
|
},
|
||||||
], 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 }) {
|
function RawExprEditor({ exprString, onChange, t }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@ -843,10 +912,13 @@ function RawExprEditor({ exprString, onChange, t }) {
|
|||||||
// Cache token inputs for estimator — auto-shown when expression uses cache vars
|
// 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: 'cr', stateKey: 'cacheReadTokens', labelKey: '缓存读取 Token (cr)' },
|
||||||
{ var: 'cc', stateKey: 'cacheCreateTokens', labelKey: '缓存创建 Token (cc)' },
|
{ var: 'cc', stateKey: 'cacheCreateTokens', labelKey: '缓存创建 Token (cc)' },
|
||||||
{ var: 'cc1h', stateKey: 'cacheCreate1hTokens', labelKey: '缓存创建-1小时 (cc1h)' },
|
{ 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({
|
function CacheTokenEstimatorInputs({
|
||||||
@ -854,25 +926,34 @@ function CacheTokenEstimatorInputs({
|
|||||||
cacheReadTokens, setCacheReadTokens,
|
cacheReadTokens, setCacheReadTokens,
|
||||||
cacheCreateTokens, setCacheCreateTokens,
|
cacheCreateTokens, setCacheCreateTokens,
|
||||||
cacheCreate1hTokens, setCacheCreate1hTokens,
|
cacheCreate1hTokens, setCacheCreate1hTokens,
|
||||||
|
imageTokens, setImageTokens,
|
||||||
|
audioInputTokens, setAudioInputTokens,
|
||||||
|
audioOutputTokens, setAudioOutputTokens,
|
||||||
t,
|
t,
|
||||||
}) {
|
}) {
|
||||||
const setters = {
|
const setters = {
|
||||||
cacheReadTokens: setCacheReadTokens,
|
cacheReadTokens: setCacheReadTokens,
|
||||||
cacheCreateTokens: setCacheCreateTokens,
|
cacheCreateTokens: setCacheCreateTokens,
|
||||||
cacheCreate1hTokens: setCacheCreate1hTokens,
|
cacheCreate1hTokens: setCacheCreate1hTokens,
|
||||||
|
imageTokens: setImageTokens,
|
||||||
|
audioInputTokens: setAudioInputTokens,
|
||||||
|
audioOutputTokens: setAudioOutputTokens,
|
||||||
};
|
};
|
||||||
const values = {
|
const values = {
|
||||||
cacheReadTokens,
|
cacheReadTokens,
|
||||||
cacheCreateTokens,
|
cacheCreateTokens,
|
||||||
cacheCreate1hTokens,
|
cacheCreate1hTokens,
|
||||||
|
imageTokens,
|
||||||
|
audioInputTokens,
|
||||||
|
audioOutputTokens,
|
||||||
};
|
};
|
||||||
|
|
||||||
const usesCache = useMemo(() => {
|
const usesExtra = useMemo(() => {
|
||||||
if (!effectiveExpr) return false;
|
if (!effectiveExpr) return false;
|
||||||
return /\b(cr|cc1h|cc)\b/.test(effectiveExpr);
|
return /\b(cr|cc1h|cc|img|ai|ao)\b/.test(effectiveExpr);
|
||||||
}, [effectiveExpr]);
|
}, [effectiveExpr]);
|
||||||
|
|
||||||
if (!usesCache) return null;
|
if (!usesExtra) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -883,7 +964,7 @@ function CacheTokenEstimatorInputs({
|
|||||||
marginBottom: 12,
|
marginBottom: 12,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{CACHE_ESTIMATOR_FIELDS.map((cf) => (
|
{EXTRA_ESTIMATOR_FIELDS.map((cf) => (
|
||||||
<div key={cf.var}>
|
<div key={cf.var}>
|
||||||
<Text size='small' className='mb-1' style={{ display: 'block' }}>
|
<Text size='small' className='mb-1' style={{ display: 'block' }}>
|
||||||
{t(cf.labelKey)}
|
{t(cf.labelKey)}
|
||||||
@ -904,7 +985,7 @@ function CacheTokenEstimatorInputs({
|
|||||||
// Cost estimator (works with any Expr string)
|
// 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 {
|
try {
|
||||||
let matchedTier = '';
|
let matchedTier = '';
|
||||||
const tierFn = (name, value) => {
|
const tierFn = (name, value) => {
|
||||||
@ -917,6 +998,17 @@ function evalExprLocally(exprStr, p, c, cr, cc, cc1h) {
|
|||||||
cr: cr || 0,
|
cr: cr || 0,
|
||||||
cc: cc || 0,
|
cc: cc || 0,
|
||||||
cc1h: cc1h || 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,
|
tier: tierFn,
|
||||||
max: Math.max,
|
max: Math.max,
|
||||||
min: Math.min,
|
min: Math.min,
|
||||||
@ -995,37 +1087,47 @@ function RuleConditionRow({ cond, onChange, onRemove, t }) {
|
|||||||
const ph = TIME_FUNC_PLACEHOLDERS[normalized.timeFunc] || '';
|
const ph = TIME_FUNC_PLACEHOLDERS[normalized.timeFunc] || '';
|
||||||
const hint = TIME_FUNC_HINTS[normalized.timeFunc] || '';
|
const hint = TIME_FUNC_HINTS[normalized.timeFunc] || '';
|
||||||
return (
|
return (
|
||||||
<div style={{ marginBottom: 6 }}>
|
<div style={{
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
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}
|
{sourceSelect}
|
||||||
<Select
|
<Select
|
||||||
size='small'
|
size='small'
|
||||||
value={normalized.timeFunc}
|
value={normalized.timeFunc}
|
||||||
onChange={(value) => onChange({ ...normalized, timeFunc: value })}
|
onChange={(value) => onChange({ ...normalized, timeFunc: value })}
|
||||||
style={{ width: 80 }}
|
style={{ flex: 1 }}
|
||||||
>
|
>
|
||||||
{TIME_FUNCS.map((fn) => (
|
{TIME_FUNCS.map((fn) => (
|
||||||
<Select.Option key={fn} value={fn}>{t(TIME_FUNC_LABELS[fn] || fn)}</Select.Option>
|
<Select.Option key={fn} value={fn}>{t(TIME_FUNC_LABELS[fn] || fn)}</Select.Option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
<Select
|
{removeBtn}
|
||||||
size='small'
|
</div>
|
||||||
value={normalized.timezone}
|
<Select
|
||||||
onChange={(value) => onChange({ ...normalized, timezone: value })}
|
size='small'
|
||||||
filter
|
value={normalized.timezone}
|
||||||
allowCreate
|
onChange={(value) => onChange({ ...normalized, timezone: value })}
|
||||||
placeholder={t('时区')}
|
filter
|
||||||
style={{ width: 180 }}
|
allowCreate
|
||||||
>
|
placeholder={t('时区')}
|
||||||
{COMMON_TIMEZONES.map((tz) => (
|
>
|
||||||
<Select.Option key={tz.value} value={tz.value}>{tz.label}</Select.Option>
|
{COMMON_TIMEZONES.map((tz) => (
|
||||||
))}
|
<Select.Option key={tz.value} value={tz.value}>{tz.label}</Select.Option>
|
||||||
</Select>
|
))}
|
||||||
|
</Select>
|
||||||
|
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||||
<Select
|
<Select
|
||||||
size='small'
|
size='small'
|
||||||
value={normalized.mode}
|
value={normalized.mode}
|
||||||
onChange={(value) => onChange(normalizeCondition({ ...normalized, mode: value }))}
|
onChange={(value) => onChange(normalizeCondition({ ...normalized, mode: value }))}
|
||||||
style={{ width: 100 }}
|
style={{ flex: 1 }}
|
||||||
>
|
>
|
||||||
{matchOptions.map((item) => (
|
{matchOptions.map((item) => (
|
||||||
<Select.Option key={item.value} value={item.value}>{item.label}</Select.Option>
|
<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 })} />
|
<Input size='small' value={normalized.rangeEnd} placeholder={ph} style={{ flex: 1 }} onChange={(value) => onChange({ ...normalized, rangeEnd: value })} />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
{hint && (
|
{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)}
|
{t(hint)}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
@ -1053,20 +1154,27 @@ function RuleConditionRow({ cond, onChange, onRemove, t }) {
|
|||||||
|
|
||||||
const showValue = normalized.mode !== MATCH_EXISTS;
|
const showValue = normalized.mode !== MATCH_EXISTS;
|
||||||
return (
|
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}
|
{sourceSelect}
|
||||||
<Input
|
<Input
|
||||||
size='small'
|
size='small'
|
||||||
value={normalized.path}
|
value={normalized.path}
|
||||||
placeholder={normalized.source === SOURCE_HEADER ? t('例如 anthropic-beta') : t('例如 service_tier')}
|
placeholder={normalized.source === SOURCE_HEADER ? t('例如 anthropic-beta') : t('例如 service_tier')}
|
||||||
onChange={(value) => onChange({ ...normalized, path: value })}
|
onChange={(value) => onChange({ ...normalized, path: value })}
|
||||||
style={{ flex: 1, minWidth: 120 }}
|
|
||||||
/>
|
/>
|
||||||
|
{removeBtn}
|
||||||
<Select
|
<Select
|
||||||
size='small'
|
size='small'
|
||||||
value={normalized.mode}
|
value={normalized.mode}
|
||||||
onChange={(value) => onChange(normalizeCondition({ ...normalized, mode: value, value: value === MATCH_EXISTS ? '' : normalized.value }))}
|
onChange={(value) => onChange(normalizeCondition({ ...normalized, mode: value, value: value === MATCH_EXISTS ? '' : normalized.value }))}
|
||||||
style={{ width: 100 }}
|
|
||||||
>
|
>
|
||||||
{matchOptions.map((item) => (
|
{matchOptions.map((item) => (
|
||||||
<Select.Option key={item.value} value={item.value}>{item.label}</Select.Option>
|
<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('匹配值')}
|
placeholder={normalized.mode === MATCH_CONTAINS ? t('匹配内容') : normalized.mode === MATCH_EXISTS ? '' : t('匹配值')}
|
||||||
disabled={!showValue}
|
disabled={!showValue}
|
||||||
onChange={(value) => onChange({ ...normalized, value })}
|
onChange={(value) => onChange({ ...normalized, value })}
|
||||||
style={{ flex: 1, minWidth: 80 }}
|
|
||||||
/>
|
/>
|
||||||
{removeBtn}
|
<div />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -1172,6 +1279,9 @@ export default function TieredPricingEditor({ model, onExprChange, requestRuleEx
|
|||||||
const [cacheReadTokens, setCacheReadTokens] = useState(0);
|
const [cacheReadTokens, setCacheReadTokens] = useState(0);
|
||||||
const [cacheCreateTokens, setCacheCreateTokens] = useState(0);
|
const [cacheCreateTokens, setCacheCreateTokens] = useState(0);
|
||||||
const [cacheCreate1hTokens, setCacheCreate1hTokens] = 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 currentRequestRuleExpr = requestRuleExpr || '';
|
||||||
const parsedRequestRuleGroups = useMemo(
|
const parsedRequestRuleGroups = useMemo(
|
||||||
@ -1282,9 +1392,11 @@ export default function TieredPricingEditor({ model, onExprChange, requestRuleEx
|
|||||||
() => evalExprLocally(
|
() => evalExprLocally(
|
||||||
effectiveExpr, promptTokens, completionTokens,
|
effectiveExpr, promptTokens, completionTokens,
|
||||||
cacheReadTokens, cacheCreateTokens, cacheCreate1hTokens,
|
cacheReadTokens, cacheCreateTokens, cacheCreate1hTokens,
|
||||||
|
imageTokens, audioInputTokens, audioOutputTokens,
|
||||||
),
|
),
|
||||||
[effectiveExpr, promptTokens, completionTokens,
|
[effectiveExpr, promptTokens, completionTokens,
|
||||||
cacheReadTokens, cacheCreateTokens, cacheCreate1hTokens],
|
cacheReadTokens, cacheCreateTokens, cacheCreate1hTokens,
|
||||||
|
imageTokens, audioInputTokens, audioOutputTokens],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -1301,24 +1413,7 @@ export default function TieredPricingEditor({ model, onExprChange, requestRuleEx
|
|||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginBottom: 12, display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 6 }}>
|
<PresetSection applyPreset={applyPreset} t={t} />
|
||||||
<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>
|
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
bodyStyle={{ padding: 16 }}
|
bodyStyle={{ padding: 16 }}
|
||||||
@ -1440,6 +1535,12 @@ export default function TieredPricingEditor({ model, onExprChange, requestRuleEx
|
|||||||
setCacheCreateTokens={setCacheCreateTokens}
|
setCacheCreateTokens={setCacheCreateTokens}
|
||||||
cacheCreate1hTokens={cacheCreate1hTokens}
|
cacheCreate1hTokens={cacheCreate1hTokens}
|
||||||
setCacheCreate1hTokens={setCacheCreate1hTokens}
|
setCacheCreate1hTokens={setCacheCreate1hTokens}
|
||||||
|
imageTokens={imageTokens}
|
||||||
|
setImageTokens={setImageTokens}
|
||||||
|
audioInputTokens={audioInputTokens}
|
||||||
|
setAudioInputTokens={setAudioInputTokens}
|
||||||
|
audioOutputTokens={audioOutputTokens}
|
||||||
|
setAudioOutputTokens={setAudioOutputTokens}
|
||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -14,17 +14,17 @@ export const MATCH_RANGE = 'range';
|
|||||||
export const TIME_FUNCS = ['hour', 'minute', 'weekday', 'month', 'day'];
|
export const TIME_FUNCS = ['hour', 'minute', 'weekday', 'month', 'day'];
|
||||||
|
|
||||||
export const COMMON_TIMEZONES = [
|
export const COMMON_TIMEZONES = [
|
||||||
{ value: 'Asia/Shanghai', label: 'CST (UTC+8 北京)' },
|
{ value: 'Asia/Shanghai', label: 'UTC+8 北京 (Asia/Shanghai)' },
|
||||||
{ value: 'UTC', label: 'UTC' },
|
{ value: 'UTC', label: 'UTC' },
|
||||||
{ value: 'America/New_York', label: 'EST (UTC-5 纽约)' },
|
{ value: 'America/New_York', label: 'UTC-5 纽约 (America/New_York)' },
|
||||||
{ value: 'America/Los_Angeles', label: 'PST (UTC-8 洛杉矶)' },
|
{ value: 'America/Los_Angeles', label: 'UTC-8 洛杉矶 (America/Los_Angeles)' },
|
||||||
{ value: 'America/Chicago', label: 'CST (UTC-6 芝加哥)' },
|
{ value: 'America/Chicago', label: 'UTC-6 芝加哥 (America/Chicago)' },
|
||||||
{ value: 'Europe/London', label: 'GMT (UTC+0 伦敦)' },
|
{ value: 'Europe/London', label: 'UTC+0 伦敦 (Europe/London)' },
|
||||||
{ value: 'Europe/Berlin', label: 'CET (UTC+1 柏林)' },
|
{ value: 'Europe/Berlin', label: 'UTC+1 柏林 (Europe/Berlin)' },
|
||||||
{ value: 'Asia/Tokyo', label: 'JST (UTC+9 东京)' },
|
{ value: 'Asia/Tokyo', label: 'UTC+9 东京 (Asia/Tokyo)' },
|
||||||
{ value: 'Asia/Singapore', label: 'SGT (UTC+8 新加坡)' },
|
{ value: 'Asia/Singapore', label: 'UTC+8 新加坡 (Asia/Singapore)' },
|
||||||
{ value: 'Asia/Seoul', label: 'KST (UTC+9 首尔)' },
|
{ value: 'Asia/Seoul', label: 'UTC+9 首尔 (Asia/Seoul)' },
|
||||||
{ value: 'Australia/Sydney', label: 'AEST (UTC+10 悉尼)' },
|
{ value: 'Australia/Sydney', label: 'UTC+10 悉尼 (Australia/Sydney)' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const NUMERIC_LITERAL_REGEX =
|
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 { useEffect, useMemo, useState } from 'react';
|
||||||
import { API, showError, showSuccess } from '../../../../helpers';
|
import { API, showError, showSuccess } from '../../../../helpers';
|
||||||
import {
|
import {
|
||||||
@ -859,7 +877,7 @@ export function useModelPricingEditorState({
|
|||||||
upsertModel(selectedModel.name, (model) => {
|
upsertModel(selectedModel.name, (model) => {
|
||||||
const next = { ...model, billingMode: value };
|
const next = { ...model, billingMode: value };
|
||||||
if (value === 'tiered_expr' && !model.billingExpr) {
|
if (value === 'tiered_expr' && !model.billingExpr) {
|
||||||
next.billingExpr = 'tier("default", p * 0 + c * 0)';
|
next.billingExpr = 'tier("base", p * 0 + c * 0)';
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user