Commit f0589cc4 by CaIon

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.
parent 91ed4e19
...@@ -10,6 +10,7 @@ import ( ...@@ -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 { ...@@ -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() { ...@@ -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) { ...@@ -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{}{ ...@@ -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 ...@@ -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 { ...@@ -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 ...@@ -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 ...@@ -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 ...@@ -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 ...@@ -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,
})
}
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 ...@@ -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 ...@@ -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 ...@@ -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 ...@@ -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, ...@@ -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, ...@@ -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, ...@@ -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) ...@@ -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 ...@@ -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 ...@@ -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) { ...@@ -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'; ...@@ -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 = ({ ...@@ -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}
......
...@@ -71,7 +71,9 @@ const ModelPricingTable = ({ ...@@ -71,7 +71,9 @@ const ModelPricingTable = ({
group: group, group: group,
ratio: groupRatioValue, ratio: groupRatioValue,
billingType: billingType:
modelData?.quota_type === 0 modelData?.billing_mode === 'tiered_expr'
? t('动态计费')
: modelData?.quota_type === 0
? t('按量计费') ? t('按量计费')
: modelData?.quota_type === 1 : modelData?.quota_type === 1
? t('按次计费') ? t('按次计费')
...@@ -94,20 +96,21 @@ const ModelPricingTable = ({ ...@@ -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 = ({ ...@@ -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,7 +130,15 @@ const ModelPricingTable = ({ ...@@ -126,7 +130,15 @@ const ModelPricingTable = ({
columns.push({ columns.push({
title: siteDisplayType === 'TOKENS' ? t('计费摘要') : t('价格摘要'), title: siteDisplayType === 'TOKENS' ? t('计费摘要') : t('价格摘要'),
dataIndex: 'priceItems', dataIndex: 'priceItems',
render: (items) => ( render: (items) => {
if (items.length === 1 && items[0].isDynamic) {
return (
<Text type='tertiary' size='small'>
{t('见上方动态计费详情')}
</Text>
);
}
return (
<div className='space-y-1'> <div className='space-y-1'>
{items.map((item) => ( {items.map((item) => (
<div key={item.key}> <div key={item.key}>
...@@ -137,7 +149,8 @@ const ModelPricingTable = ({ ...@@ -137,7 +149,8 @@ const ModelPricingTable = ({
</div> </div>
))} ))}
</div> </div>
), );
},
}); });
return ( return (
......
...@@ -38,6 +38,7 @@ import { ...@@ -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 = ({ ...@@ -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 { ...@@ -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) { ...@@ -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) { ...@@ -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',
),
}; };
} }
......
...@@ -645,7 +645,17 @@ export const calculateModelPrice = ({ ...@@ -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 = ( ...@@ -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 = ( ...@@ -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);
......
...@@ -36,6 +36,7 @@ import { ...@@ -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 = () => { ...@@ -407,43 +408,14 @@ export const useLogsData = () => {
}); });
} }
if (logs[i].type === 2) { if (logs[i].type === 2) {
if (other?.billing_mode !== 'tiered_expr') {
expandDataLocal.push({ expandDataLocal.push({
key: t('日志详情'), key: t('日志详情'),
value: other?.claude value: other?.claude
? renderClaudeLogContent( ? renderClaudeLogContent({ ...other, displayMode: billingDisplayMode })
other?.model_ratio, : renderLogContent({ ...other, displayMode: billingDisplayMode }),
other.completion_ratio,
other.model_price,
other.group_ratio,
other?.user_group_ratio,
other.cache_ratio || 1.0,
other.cache_creation_ratio || 1.0,
other.cache_creation_tokens_5m || 0,
other.cache_creation_ratio_5m ||
other.cache_creation_ratio ||
1.0,
other.cache_creation_tokens_1h || 0,
other.cache_creation_ratio_1h ||
other.cache_creation_ratio ||
1.0,
billingDisplayMode,
)
: renderLogContent(
other?.model_ratio,
other.completion_ratio,
other.model_price,
other.group_ratio,
other?.user_group_ratio,
other.cache_ratio || 1.0,
false,
1.0,
other.web_search || false,
other.web_search_call_count || 0,
other.file_search || false,
other.file_search_call_count || 0,
billingDisplayMode,
),
}); });
}
if (logs[i]?.content) { if (logs[i]?.content) {
expandDataLocal.push({ expandDataLocal.push({
key: t('其他详情'), key: t('其他详情'),
...@@ -479,74 +451,19 @@ export const useLogsData = () => { ...@@ -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,67 +476,17 @@ export const useLogsData = () => { ...@@ -559,67 +476,17 @@ 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({
key: t('计费方式'),
value: t('阶梯计费'),
});
if (other?.group_ratio !== undefined) {
const gr = other.group_ratio;
expandDataLocal.push({
key: t('分组倍率'),
value: typeof gr === 'number' ? gr.toFixed(4) : String(gr ?? '-'),
});
}
if (other?.rule_version !== undefined) {
expandDataLocal.push({
key: t('规则版本'),
value: String(other.rule_version),
});
}
if (other?.estimated_env) {
expandDataLocal.push({ expandDataLocal.push({
key: t('预估环境'), key: t('计费过程'),
value: `prompt=${other.estimated_env.prompt_tokens ?? 0}, completion=${other.estimated_env.completion_tokens ?? 0}`, value: renderTieredModelPrice({
}); ...other,
} prompt_tokens: logs[i].prompt_tokens,
if (other?.actual_env) { completion_tokens: logs[i].completion_tokens,
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) {
if (other?.task_id) { if (other?.task_id) {
expandDataLocal.push({ expandDataLocal.push({
......
...@@ -3421,6 +3421,23 @@ ...@@ -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"
} }
} }
...@@ -3048,6 +3048,23 @@ ...@@ -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",
"见上方动态计费详情": "见上方动态计费详情",
"分组倍率": "分组倍率",
"含时间条件": "含时间条件",
"含请求条件": "含请求条件",
"输入": "输入",
"输出": "输出",
"档": "档"
} }
} }
...@@ -865,6 +865,24 @@ html.dark .with-pastel-balls::before { ...@@ -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({ ...@@ -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
......
...@@ -14,17 +14,17 @@ export const MATCH_RANGE = 'range'; ...@@ -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 =
......
/*
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({ ...@@ -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;
}); });
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment