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 (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/setting/billing_setting"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/QuantumNous/new-api/types"
)
......@@ -32,6 +33,8 @@ type Pricing struct {
AudioCompletionRatio *float64 `json:"audio_completion_ratio,omitempty"`
EnableGroup []string `json:"enable_groups"`
SupportedEndpointTypes []constant.EndpointType `json:"supported_endpoint_types"`
BillingMode string `json:"billing_mode,omitempty"`
BillingExpr string `json:"billing_expr,omitempty"`
PricingVersion string `json:"pricing_version,omitempty"`
}
......@@ -319,6 +322,12 @@ func updatePricing() {
audioCompletionRatio := ratio_setting.GetAudioCompletionRatio(model)
pricing.AudioCompletionRatio = &audioCompletionRatio
}
if billingMode := billing_setting.GetBillingMode(model); billingMode == "tiered_expr" {
pricing.BillingMode = billingMode
if expr, ok := billing_setting.GetBillingExpr(model); ok {
pricing.BillingExpr = expr
}
}
pricingMap = append(pricingMap, pricing)
}
......
......@@ -931,3 +931,55 @@ func TestTimeFunctions_MonthDayPattern(t *testing.T) {
t.Errorf("cost = %f, want 1000 or 500", cost)
}
}
// ---------------------------------------------------------------------------
// Image and audio token tests
// ---------------------------------------------------------------------------
func TestImageTokenVariable(t *testing.T) {
exprStr := `tier("base", p * 2 + c * 10 + img * 5)`
cost, _, err := billingexpr.RunExpr(exprStr, billingexpr.TokenParams{P: 1000, C: 500, Img: 200})
if err != nil {
t.Fatal(err)
}
// 1000*2 + 500*10 + 200*5 = 2000 + 5000 + 1000 = 8000
if math.Abs(cost-8000) > 1e-6 {
t.Errorf("cost = %f, want 8000", cost)
}
}
func TestAudioTokenVariables(t *testing.T) {
exprStr := `tier("base", p * 2 + c * 10 + ai * 50 + ao * 100)`
cost, _, err := billingexpr.RunExpr(exprStr, billingexpr.TokenParams{P: 1000, C: 500, AI: 100, AO: 50})
if err != nil {
t.Fatal(err)
}
// 1000*2 + 500*10 + 100*50 + 50*100 = 2000 + 5000 + 5000 + 5000 = 17000
if math.Abs(cost-17000) > 1e-6 {
t.Errorf("cost = %f, want 17000", cost)
}
}
func TestImageAudioAliases(t *testing.T) {
exprStr := `tier("base", prompt_tokens * 1 + image_tokens * 3 + audio_input_tokens * 5 + audio_output_tokens * 10)`
cost, _, err := billingexpr.RunExpr(exprStr, billingexpr.TokenParams{P: 100, Img: 50, AI: 20, AO: 10})
if err != nil {
t.Fatal(err)
}
// 100*1 + 50*3 + 20*5 + 10*10 = 100 + 150 + 100 + 100 = 450
if math.Abs(cost-450) > 1e-6 {
t.Errorf("cost = %f, want 450", cost)
}
}
func TestImageAudioZero(t *testing.T) {
exprStr := `tier("base", p * 2 + img * 5 + ai * 50 + ao * 100)`
cost, _, err := billingexpr.RunExpr(exprStr, billingexpr.TokenParams{P: 1000})
if err != nil {
t.Fatal(err)
}
// img, ai, ao default to 0
if math.Abs(cost-2000) > 1e-6 {
t.Errorf("cost = %f, want 2000", cost)
}
}
......@@ -31,6 +31,12 @@ var compileEnvPrototype = map[string]interface{}{
"cache_read_tokens": float64(0),
"cache_create_tokens": float64(0),
"cache_create_1h_tokens": float64(0),
"img": float64(0),
"ai": float64(0),
"ao": float64(0),
"image_tokens": float64(0),
"audio_input_tokens": float64(0),
"audio_output_tokens": float64(0),
"tier": func(string, float64) float64 { return 0 },
"header": func(string) string { return "" },
"param": func(string) interface{} { return nil },
......
......@@ -62,6 +62,12 @@ func runProgram(prog *vm.Program, params TokenParams, request RequestInput) (flo
"cache_read_tokens": params.CR,
"cache_create_tokens": params.CC,
"cache_create_1h_tokens": params.CC1h,
"img": params.Img,
"ai": params.AI,
"ao": params.AO,
"image_tokens": params.Img,
"audio_input_tokens": params.AI,
"audio_output_tokens": params.AO,
"tier": func(name string, value float64) float64 {
trace.MatchedTier = name
trace.Cost = value
......
......@@ -14,11 +14,14 @@ type RequestInput struct {
// Fields beyond P and C are optional — when absent they default to 0,
// which means cache-unaware expressions keep working unchanged.
type TokenParams struct {
P float64 // prompt tokens
C float64 // completion tokens
P float64 // prompt tokens (text)
C float64 // completion tokens (text)
CR float64 // cache read (hit) tokens
CC float64 // cache creation tokens (5-min TTL for Claude, generic for others)
CC1h float64 // cache creation tokens — 1-hour TTL (Claude only)
Img float64 // image input tokens
AI float64 // audio input tokens
AO float64 // audio output tokens
}
// TraceResult holds side-channel info captured by the tier() function
......
......@@ -237,16 +237,20 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
service.ObserveChannelAffinityUsageCacheByRelayFormat(ctx, usage, relayInfo.GetFinalRequestRelayFormat())
}
// Tiered billing early return
if ok, tieredQuota, tieredResult := service.TryTieredSettle(relayInfo, billingexpr.TokenParams{
// Tiered billing: only determines quota, logging continues through normal path
var tieredResult *billingexpr.TieredResult
tieredOk, tieredQuota, tieredRes := service.TryTieredSettle(relayInfo, billingexpr.TokenParams{
P: float64(usage.PromptTokens),
C: float64(usage.CompletionTokens),
CR: float64(usage.PromptTokensDetails.CachedTokens),
CC: float64(usage.PromptTokensDetails.CachedCreationTokens - usage.ClaudeCacheCreation1hTokens),
CC1h: float64(usage.ClaudeCacheCreation1hTokens),
}); ok {
postConsumeQuotaTiered(ctx, relayInfo, usage, tieredQuota, tieredResult, extraContent...)
return
Img: float64(usage.PromptTokensDetails.ImageTokens),
AI: float64(usage.PromptTokensDetails.AudioTokens),
AO: float64(usage.CompletionTokenDetails.AudioTokens),
})
if tieredOk {
tieredResult = tieredRes
}
adminRejectReason := common.GetContextKeyString(ctx, constant.ContextKeyAdminRejectReason)
......@@ -419,10 +423,11 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
}
quota := int(quotaCalculateDecimal.Round(0).IntPart())
if tieredOk {
quota = tieredQuota
}
totalTokens := promptTokens + completionTokens
//var logContent string
// record all the consume log even if quota is 0
if totalTokens == 0 {
// in this case, must be some error happened
......@@ -504,6 +509,9 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
other["image_generation_call"] = true
other["image_generation_call_price"] = imageGenerationCallPrice
}
if tieredResult != nil {
service.InjectTieredBillingInfo(other, relayInfo, tieredResult)
}
model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{
ChannelId: relayInfo.ChannelId,
PromptTokens: promptTokens,
......@@ -519,51 +527,3 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
Other: other,
})
}
func postConsumeQuotaTiered(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, quota int, tieredResult *service.TieredResultWrapper, extraContent ...string) {
_ = tieredResult // will be used for log enrichment
useTimeSeconds := time.Now().Unix() - relayInfo.StartTime.Unix()
modelName := relayInfo.OriginModelName
tokenName := ctx.GetString("token_name")
groupRatio := relayInfo.PriceData.GroupRatioInfo.GroupRatio
totalTokens := usage.PromptTokens + usage.CompletionTokens
if totalTokens == 0 {
quota = 0
extraContent = append(extraContent, "上游没有返回计费信息,无法扣费(可能是上游超时)")
logger.LogError(ctx, fmt.Sprintf("tiered billing: total tokens is 0, userId %d, channelId %d, tokenId %d, model %s, pre-consumed %d",
relayInfo.UserId, relayInfo.ChannelId, relayInfo.TokenId, modelName, relayInfo.FinalPreConsumedQuota))
} else {
if groupRatio != 0 && quota == 0 {
quota = 1
}
model.UpdateUserUsedQuotaAndRequestCount(relayInfo.UserId, quota)
model.UpdateChannelUsedQuota(relayInfo.ChannelId, quota)
}
if err := service.SettleBilling(ctx, relayInfo, quota); err != nil {
logger.LogError(ctx, "error settling tiered billing: "+err.Error())
}
logModel := modelName
logContent := strings.Join(extraContent, ", ")
other := service.GenerateTieredOtherInfo(ctx, relayInfo, tieredResult)
model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{
ChannelId: relayInfo.ChannelId,
PromptTokens: usage.PromptTokens,
CompletionTokens: usage.CompletionTokens,
ModelName: logModel,
TokenName: tokenName,
Quota: quota,
Content: logContent,
TokenId: relayInfo.TokenId,
UseTimeSeconds: int(useTimeSeconds),
IsStream: relayInfo.IsStream,
Group: relayInfo.UsingGroup,
Other: other,
})
}
package service
import (
"encoding/base64"
"strings"
"github.com/QuantumNous/new-api/common"
......@@ -216,41 +217,17 @@ func GenerateMjOtherInfo(relayInfo *relaycommon.RelayInfo, priceData types.Price
return other
}
func GenerateTieredOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, result *billingexpr.TieredResult) map[string]interface{} {
other := make(map[string]interface{})
other["billing_mode"] = "tiered_expr"
// InjectTieredBillingInfo overlays tiered billing fields onto an existing
// module-specific other map. Call this after GenerateTextOtherInfo /
// GenerateClaudeOtherInfo / etc. when the request used tiered_expr billing.
func InjectTieredBillingInfo(other map[string]interface{}, relayInfo *relaycommon.RelayInfo, result *billingexpr.TieredResult) {
snap := relayInfo.TieredBillingSnapshot
if snap != nil {
other["group_ratio"] = snap.GroupRatio
other["expr_hash"] = snap.ExprHash
other["estimated_prompt_tokens"] = snap.EstimatedPromptTokens
other["estimated_completion_tokens"] = snap.EstimatedCompletionTokens
other["estimated_quota_before_group"] = snap.EstimatedQuotaBeforeGroup
other["estimated_quota_after_group"] = snap.EstimatedQuotaAfterGroup
other["estimated_tier"] = snap.EstimatedTier
if snap == nil {
return
}
other["billing_mode"] = "tiered_expr"
other["expr_b64"] = base64.StdEncoding.EncodeToString([]byte(snap.ExprString))
if result != nil {
other["actual_quota_before_group"] = result.ActualQuotaBeforeGroup
other["actual_quota_after_group"] = result.ActualQuotaAfterGroup
other["matched_tier"] = result.MatchedTier
other["crossed_tier"] = result.CrossedTier
}
other["frt"] = float64(relayInfo.FirstResponseTime.UnixMilli() - relayInfo.StartTime.UnixMilli())
if relayInfo.IsModelMapped {
other["is_model_mapped"] = true
other["upstream_model_name"] = relayInfo.UpstreamModelName
}
adminInfo := make(map[string]interface{})
adminInfo["use_channel"] = ctx.GetStringSlice("use_channel")
AppendChannelAffinityAdminInfo(ctx, adminInfo)
other["admin_info"] = adminInfo
appendRequestPath(ctx, relayInfo, other)
appendRequestConversionChain(relayInfo, other)
appendBillingInfo(relayInfo, other)
return other
}
......@@ -158,13 +158,13 @@ func PreWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usag
func PostWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, modelName string,
usage *dto.RealtimeUsage, extraContent string) {
// Tiered billing early return
if ok, tieredQuota, tieredResult := TryTieredSettle(relayInfo, billingexpr.TokenParams{
var tieredResult *billingexpr.TieredResult
tieredOk, tieredQuota, tieredRes := TryTieredSettle(relayInfo, billingexpr.TokenParams{
P: float64(usage.InputTokens),
C: float64(usage.OutputTokens),
}); ok {
postConsumeQuotaTieredService(ctx, relayInfo, modelName, usage.InputTokens, usage.OutputTokens, usage.TotalTokens, tieredQuota, tieredResult, extraContent)
return
})
if tieredOk {
tieredResult = tieredRes
}
useTimeSeconds := time.Now().Unix() - relayInfo.StartTime.Unix()
......@@ -200,6 +200,9 @@ func PostWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, mod
}
quota := calculateAudioQuota(quotaInfo)
if tieredOk {
quota = tieredQuota
}
totalTokens := usage.TotalTokens
var logContent string
......@@ -229,6 +232,9 @@ func PostWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, mod
}
other := GenerateWssOtherInfo(ctx, relayInfo, usage, modelRatio, groupRatio,
completionRatio.InexactFloat64(), audioRatio.InexactFloat64(), audioCompletionRatio.InexactFloat64(), modelPrice, relayInfo.PriceData.GroupRatioInfo.GroupSpecialRatio)
if tieredResult != nil {
InjectTieredBillingInfo(other, relayInfo, tieredResult)
}
model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{
ChannelId: relayInfo.ChannelId,
PromptTokens: usage.InputTokens,
......@@ -250,16 +256,16 @@ func PostClaudeConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
ObserveChannelAffinityUsageCacheByRelayFormat(ctx, usage, relayInfo.GetFinalRequestRelayFormat())
}
// Tiered billing early return
if ok, tieredQuota, tieredResult := TryTieredSettle(relayInfo, billingexpr.TokenParams{
var tieredResult *billingexpr.TieredResult
tieredOk, tieredQuota, tieredRes := TryTieredSettle(relayInfo, billingexpr.TokenParams{
P: float64(usage.PromptTokens),
C: float64(usage.CompletionTokens),
CR: float64(usage.PromptTokensDetails.CachedTokens),
CC: float64(usage.PromptTokensDetails.CachedCreationTokens - usage.ClaudeCacheCreation1hTokens),
CC1h: float64(usage.ClaudeCacheCreation1hTokens),
}); ok {
postConsumeQuotaTieredService(ctx, relayInfo, relayInfo.OriginModelName, usage.PromptTokens, usage.CompletionTokens, usage.PromptTokens+usage.CompletionTokens, tieredQuota, tieredResult, "")
return
})
if tieredOk {
tieredResult = tieredRes
}
useTimeSeconds := time.Now().Unix() - relayInfo.StartTime.Unix()
......@@ -315,6 +321,9 @@ func PostClaudeConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
}
quota := int(calculateQuota)
if tieredOk {
quota = tieredQuota
}
totalTokens := promptTokens + completionTokens
......@@ -342,6 +351,9 @@ func PostClaudeConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
cacheCreationTokens5m, cacheCreationRatio5m,
cacheCreationTokens1h, cacheCreationRatio1h,
modelPrice, relayInfo.PriceData.GroupRatioInfo.GroupSpecialRatio)
if tieredResult != nil {
InjectTieredBillingInfo(other, relayInfo, tieredResult)
}
model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{
ChannelId: relayInfo.ChannelId,
PromptTokens: promptTokens,
......@@ -382,14 +394,16 @@ func CalcOpenRouterCacheCreateTokens(usage dto.Usage, priceData types.PriceData)
func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, extraContent string) {
// Tiered billing early return
if ok, tieredQuota, tieredResult := TryTieredSettle(relayInfo, billingexpr.TokenParams{
var tieredResult *billingexpr.TieredResult
tieredOk, tieredQuota, tieredRes := TryTieredSettle(relayInfo, billingexpr.TokenParams{
P: float64(usage.PromptTokens),
C: float64(usage.CompletionTokens),
CR: float64(usage.PromptTokensDetails.CachedTokens),
}); ok {
postConsumeQuotaTieredService(ctx, relayInfo, relayInfo.OriginModelName, usage.PromptTokens, usage.CompletionTokens, usage.TotalTokens, tieredQuota, tieredResult, extraContent)
return
AI: float64(usage.PromptTokensDetails.AudioTokens),
AO: float64(usage.CompletionTokenDetails.AudioTokens),
})
if tieredOk {
tieredResult = tieredRes
}
useTimeSeconds := time.Now().Unix() - relayInfo.StartTime.Unix()
......@@ -425,6 +439,9 @@ func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, u
}
quota := calculateAudioQuota(quotaInfo)
if tieredOk {
quota = tieredQuota
}
totalTokens := usage.TotalTokens
var logContent string
......@@ -458,6 +475,9 @@ func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, u
}
other := GenerateAudioOtherInfo(ctx, relayInfo, usage, modelRatio, groupRatio,
completionRatio.InexactFloat64(), audioRatio.InexactFloat64(), audioCompletionRatio.InexactFloat64(), modelPrice, relayInfo.PriceData.GroupRatioInfo.GroupSpecialRatio)
if tieredResult != nil {
InjectTieredBillingInfo(other, relayInfo, tieredResult)
}
model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{
ChannelId: relayInfo.ChannelId,
PromptTokens: usage.PromptTokens,
......@@ -640,49 +660,3 @@ func checkAndSendSubscriptionQuotaNotify(relayInfo *relaycommon.RelayInfo) {
})
}
func postConsumeQuotaTieredService(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, modelName string,
promptTokens, completionTokens, totalTokens, quota int, tieredResult *TieredResultWrapper, extraContent string) {
useTimeSeconds := time.Now().Unix() - relayInfo.StartTime.Unix()
tokenName := ctx.GetString("token_name")
groupRatio := relayInfo.PriceData.GroupRatioInfo.GroupRatio
var logContent string
if totalTokens == 0 {
quota = 0
logContent = "上游没有返回计费信息(可能是上游超时)"
logger.LogError(ctx, fmt.Sprintf("tiered billing: total tokens is 0, userId %d, channelId %d, tokenId %d, model %s, pre-consumed %d",
relayInfo.UserId, relayInfo.ChannelId, relayInfo.TokenId, modelName, relayInfo.FinalPreConsumedQuota))
} else {
if groupRatio != 0 && quota == 0 {
quota = 1
}
model.UpdateUserUsedQuotaAndRequestCount(relayInfo.UserId, quota)
model.UpdateChannelUsedQuota(relayInfo.ChannelId, quota)
}
if err := SettleBilling(ctx, relayInfo, quota); err != nil {
logger.LogError(ctx, "error settling tiered billing: "+err.Error())
}
if extraContent != "" {
logContent += extraContent
}
other := GenerateTieredOtherInfo(ctx, relayInfo, tieredResult)
model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{
ChannelId: relayInfo.ChannelId,
PromptTokens: promptTokens,
CompletionTokens: completionTokens,
ModelName: modelName,
TokenName: tokenName,
Quota: quota,
Content: logContent,
TokenId: relayInfo.TokenId,
UseTimeSeconds: int(useTimeSeconds),
IsStream: relayInfo.IsStream,
Group: relayInfo.UsingGroup,
Other: other,
})
}
......@@ -26,6 +26,7 @@ import ModelHeader from './components/ModelHeader';
import ModelBasicInfo from './components/ModelBasicInfo';
import ModelEndpoints from './components/ModelEndpoints';
import ModelPricingTable from './components/ModelPricingTable';
import DynamicPricingBreakdown from './components/DynamicPricingBreakdown';
const { Text } = Typography;
......@@ -89,6 +90,12 @@ const ModelDetailSideSheet = ({
endpointMap={endpointMap}
t={t}
/>
{modelData.billing_mode === 'tiered_expr' && modelData.billing_expr && (
<DynamicPricingBreakdown
billingExpr={modelData.billing_expr}
t={t}
/>
)}
<ModelPricingTable
modelData={modelData}
groupRatio={groupRatio}
......
......@@ -71,11 +71,13 @@ const ModelPricingTable = ({
group: group,
ratio: groupRatioValue,
billingType:
modelData?.quota_type === 0
? t('按量计费')
: modelData?.quota_type === 1
? t('按次计费')
: '-',
modelData?.billing_mode === 'tiered_expr'
? t('动态计费')
: modelData?.quota_type === 0
? t('按量计费')
: modelData?.quota_type === 1
? t('按次计费')
: '-',
priceItems: getModelPriceItems(priceData, t, siteDisplayType),
};
});
......@@ -94,20 +96,21 @@ const ModelPricingTable = ({
},
];
// 如果显示倍率,添加倍率列
if (showRatio) {
const isDynamic = modelData?.billing_mode === 'tiered_expr';
// 动态计费时始终显示倍率列,否则根据设置
if (showRatio || isDynamic) {
columns.push({
title: t('倍率'),
title: t('分组倍率'),
dataIndex: 'ratio',
render: (text) => (
<Tag color='white' size='small' shape='circle'>
<Tag color='blue' size='small' shape='circle'>
{text}x
</Tag>
),
});
}
// 添加计费类型列
columns.push({
title: t('计费类型'),
dataIndex: 'billingType',
......@@ -115,6 +118,7 @@ const ModelPricingTable = ({
let color = 'white';
if (text === t('按量计费')) color = 'violet';
else if (text === t('按次计费')) color = 'teal';
else if (text === t('动态计费')) color = 'amber';
return (
<Tag color={color} size='small' shape='circle'>
{text || '-'}
......@@ -126,18 +130,27 @@ const ModelPricingTable = ({
columns.push({
title: siteDisplayType === 'TOKENS' ? t('计费摘要') : t('价格摘要'),
dataIndex: 'priceItems',
render: (items) => (
<div className='space-y-1'>
{items.map((item) => (
<div key={item.key}>
<div className='font-semibold text-orange-600'>
{item.label} {item.value}
render: (items) => {
if (items.length === 1 && items[0].isDynamic) {
return (
<Text type='tertiary' size='small'>
{t('见上方动态计费详情')}
</Text>
);
}
return (
<div className='space-y-1'>
{items.map((item) => (
<div key={item.key}>
<div className='font-semibold text-orange-600'>
{item.label} {item.value}
</div>
<div className='text-xs text-gray-500'>{item.suffix}</div>
</div>
<div className='text-xs text-gray-500'>{item.suffix}</div>
</div>
))}
</div>
),
))}
</div>
);
},
});
return (
......
......@@ -38,6 +38,7 @@ import {
stringToColor,
calculateModelPrice,
formatPriceInfo,
formatDynamicPriceSummary,
getLobeHubIcon,
} from '../../../../../helpers';
import PricingCardSkeleton from './PricingCardSkeleton';
......@@ -267,7 +268,11 @@ const PricingCardView = ({
{model.model_name}
</h3>
<div className='flex flex-col gap-1 text-xs mt-1'>
{formatPriceInfo(priceData, t, siteDisplayType)}
{priceData.isDynamicPricing ? (
formatDynamicPriceSummary(priceData.billingExpr, t)
) : (
formatPriceInfo(priceData, t, siteDisplayType)
)}
</div>
</div>
</div>
......
......@@ -33,6 +33,7 @@ import {
getLogOther,
renderModelTag,
renderModelPriceSimple,
renderTieredModelPriceSimple,
} from '../../../helpers';
import { IconHelpCircle } from '@douyinfe/semi-icons';
import { Route, Sparkles } from 'lucide-react';
......@@ -377,43 +378,6 @@ function renderCompactDetailSummary(summarySegments) {
);
}
function buildTieredBillingSegments(other, t) {
const segments = [
{ text: `${t('阶梯计费')}`, tone: 'primary' },
];
if (other.matched_tier) {
segments.push({
text: `${t('命中档位')}: ${other.matched_tier}`,
tone: 'secondary',
});
}
const groupRatio = other.group_ratio;
if (groupRatio !== undefined && groupRatio !== null) {
segments.push({
text: `${t('分组')} ${formatRatio(groupRatio)}x`,
tone: 'secondary',
});
}
if (other.crossed_tier) {
segments.push({
text: `${t('跨阶梯')}: ${t('是')}`,
tone: 'secondary',
});
}
if (other.actual_quota_after_group !== undefined) {
segments.push({
text: `${t('实际额度')}: ${other.actual_quota_after_group}`,
tone: 'secondary',
});
}
return { segments };
}
function getUsageLogDetailSummary(record, text, billingDisplayMode, t) {
const other = getLogOther(record.other);
......@@ -451,52 +415,16 @@ function getUsageLogDetailSummary(record, text, billingDisplayMode, t) {
};
}
const summaryOpts = { ...other, displayMode: billingDisplayMode, outputMode: 'segments' };
if (other?.billing_mode === 'tiered_expr') {
return buildTieredBillingSegments(other, t);
return { segments: renderTieredModelPriceSimple(summaryOpts) };
}
return {
segments: other?.claude
? renderModelPriceSimple(
other.model_ratio,
other.model_price,
other.group_ratio,
other?.user_group_ratio,
other.cache_tokens || 0,
other.cache_ratio || 1.0,
other.cache_creation_tokens || 0,
other.cache_creation_ratio || 1.0,
other.cache_creation_tokens_5m || 0,
other.cache_creation_ratio_5m || other.cache_creation_ratio || 1.0,
other.cache_creation_tokens_1h || 0,
other.cache_creation_ratio_1h || other.cache_creation_ratio || 1.0,
false,
1.0,
other?.is_system_prompt_overwritten,
'claude',
billingDisplayMode,
'segments',
)
: renderModelPriceSimple(
other.model_ratio,
other.model_price,
other.group_ratio,
other?.user_group_ratio,
other.cache_tokens || 0,
other.cache_ratio || 1.0,
0,
1.0,
0,
1.0,
0,
1.0,
false,
1.0,
other?.is_system_prompt_overwritten,
'openai',
billingDisplayMode,
'segments',
),
? renderModelPriceSimple({ ...summaryOpts, provider: 'claude' })
: renderModelPriceSimple({ ...summaryOpts, provider: 'openai' }),
};
}
......
......@@ -645,7 +645,17 @@ export const calculateModelPrice = ({
}
}
// 2. 根据计费类型计算价格
// 2. 动态计费(tiered_expr)
if (record.billing_mode === 'tiered_expr' && record.billing_expr) {
return {
isDynamicPricing: true,
billingExpr: record.billing_expr,
usedGroup,
usedGroupRatio,
};
}
// 3. 根据计费类型计算价格
if (record.quota_type === 0) {
// 按量计费
const isTokensDisplay = quotaDisplayType === 'TOKENS';
......@@ -766,6 +776,18 @@ export const getModelPriceItems = (
t,
quotaDisplayType = 'USD',
) => {
if (priceData.isDynamicPricing) {
return [
{
key: 'dynamic',
label: t('动态计费'),
value: '',
suffix: '',
isDynamic: true,
},
];
}
if (priceData.isPerToken) {
if (quotaDisplayType === 'TOKENS' || priceData.isTokensDisplay) {
return [
......@@ -874,6 +896,83 @@ export const getModelPriceItems = (
].filter((item) => item.value !== null && item.value !== undefined && item.value !== '');
};
// 格式化动态计费摘要(用于卡片视图,与 formatPriceInfo 风格统一)
export const formatDynamicPriceSummary = (billingExpr, t) => {
if (!billingExpr) return <span style={{ color: 'var(--semi-color-text-1)' }}>{t('动态计费')}</span>;
const tierMatches = billingExpr.match(/tier\(/g) || [];
const tierCount = tierMatches.length;
const firstTierMatch = billingExpr.match(
/tier\("[^"]*",\s*p\s*\*\s*([\d.eE+-]+)\s*\+\s*c\s*\*\s*([\d.eE+-]+)(?:\s*\+\s*cr\s*\*\s*([\d.eE+-]+))?(?:\s*\+\s*cc\s*\*\s*([\d.eE+-]+))?/,
);
const hasTimeCondition = /\b(?:hour|weekday|month|day)\(/.test(billingExpr);
const hasRequestCondition = /\b(?:param|header)\(/.test(billingExpr);
const tags = [];
if (tierCount > 1) tags.push(`${tierCount}${t('档')}`);
if (hasTimeCondition) tags.push(t('含时间条件'));
if (hasRequestCondition) tags.push(t('含请求条件'));
const unitSuffix = ' / 1M Tokens';
const lineStyle = { color: 'var(--semi-color-text-1)' };
return (
<>
{firstTierMatch && (
<>
<span style={lineStyle}>
{t('输入价格')} ${(Number(firstTierMatch[1]) * 2).toFixed(4)}{unitSuffix}
</span>
<span style={lineStyle}>
{t('输出价格')} ${(Number(firstTierMatch[2]) * 2).toFixed(4)}{unitSuffix}
</span>
{firstTierMatch[3] && (
<span style={lineStyle}>
{t('缓存读取价格')} ${(Number(firstTierMatch[3]) * 2).toFixed(4)}{unitSuffix}
</span>
)}
{firstTierMatch[4] && (
<span style={lineStyle}>
{t('缓存创建价格')} ${(Number(firstTierMatch[4]) * 2).toFixed(4)}{unitSuffix}
</span>
)}
</>
)}
<span style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
<span
style={{
display: 'inline-block',
padding: '1px 6px',
borderRadius: 4,
fontSize: 11,
background: 'var(--semi-color-warning-light-default)',
color: 'var(--semi-color-warning)',
}}
>
{t('动态计费')}
</span>
{tags.map((tag) => (
<span
key={tag}
style={{
display: 'inline-block',
padding: '1px 6px',
borderRadius: 4,
fontSize: 11,
background: 'var(--semi-color-fill-1)',
color: 'var(--semi-color-text-2)',
}}
>
{tag}
</span>
))}
</span>
</>
);
};
// 格式化价格信息(用于卡片视图)
export const formatPriceInfo = (priceData, t, quotaDisplayType = 'USD') => {
const items = getModelPriceItems(priceData, t, quotaDisplayType);
......
......@@ -36,6 +36,7 @@ import {
renderAudioModelPrice,
renderClaudeModelPrice,
renderModelPrice,
renderTieredModelPrice,
} from '../../helpers';
import { ITEMS_PER_PAGE } from '../../constants';
import { useTableCompactMode } from '../common/useTableCompactMode';
......@@ -407,43 +408,14 @@ export const useLogsData = () => {
});
}
if (logs[i].type === 2) {
expandDataLocal.push({
key: t('日志详情'),
value: other?.claude
? renderClaudeLogContent(
other?.model_ratio,
other.completion_ratio,
other.model_price,
other.group_ratio,
other?.user_group_ratio,
other.cache_ratio || 1.0,
other.cache_creation_ratio || 1.0,
other.cache_creation_tokens_5m || 0,
other.cache_creation_ratio_5m ||
other.cache_creation_ratio ||
1.0,
other.cache_creation_tokens_1h || 0,
other.cache_creation_ratio_1h ||
other.cache_creation_ratio ||
1.0,
billingDisplayMode,
)
: renderLogContent(
other?.model_ratio,
other.completion_ratio,
other.model_price,
other.group_ratio,
other?.user_group_ratio,
other.cache_ratio || 1.0,
false,
1.0,
other.web_search || false,
other.web_search_call_count || 0,
other.file_search || false,
other.file_search_call_count || 0,
billingDisplayMode,
),
});
if (other?.billing_mode !== 'tiered_expr') {
expandDataLocal.push({
key: t('日志详情'),
value: other?.claude
? renderClaudeLogContent({ ...other, displayMode: billingDisplayMode })
: renderLogContent({ ...other, displayMode: billingDisplayMode }),
});
}
if (logs[i]?.content) {
expandDataLocal.push({
key: t('其他详情'),
......@@ -479,74 +451,19 @@ export const useLogsData = () => {
Boolean(other?.violation_fee_marker);
let content = '';
if (!isViolationFeeLog) {
if (!isViolationFeeLog && other?.billing_mode !== 'tiered_expr') {
const logOpts = {
...other,
prompt_tokens: logs[i].prompt_tokens,
completion_tokens: logs[i].completion_tokens,
displayMode: billingDisplayMode,
};
if (other?.ws || other?.audio) {
content = renderAudioModelPrice(
other?.text_input,
other?.text_output,
other?.model_ratio,
other?.model_price,
other?.completion_ratio,
other?.audio_input,
other?.audio_output,
other?.audio_ratio,
other?.audio_completion_ratio,
other?.group_ratio,
other?.user_group_ratio,
other?.cache_tokens || 0,
other?.cache_ratio || 1.0,
billingDisplayMode,
);
content = renderAudioModelPrice(logOpts);
} else if (other?.claude) {
content = renderClaudeModelPrice(
logs[i].prompt_tokens,
logs[i].completion_tokens,
other.model_ratio,
other.model_price,
other.completion_ratio,
other.group_ratio,
other?.user_group_ratio,
other.cache_tokens || 0,
other.cache_ratio || 1.0,
other.cache_creation_tokens || 0,
other.cache_creation_ratio || 1.0,
other.cache_creation_tokens_5m || 0,
other.cache_creation_ratio_5m ||
other.cache_creation_ratio ||
1.0,
other.cache_creation_tokens_1h || 0,
other.cache_creation_ratio_1h ||
other.cache_creation_ratio ||
1.0,
billingDisplayMode,
);
content = renderClaudeModelPrice(logOpts);
} else {
content = renderModelPrice(
logs[i].prompt_tokens,
logs[i].completion_tokens,
other?.model_ratio,
other?.model_price,
other?.completion_ratio,
other?.group_ratio,
other?.user_group_ratio,
other?.cache_tokens || 0,
other?.cache_ratio || 1.0,
other?.image || false,
other?.image_ratio || 0,
other?.image_output || 0,
other?.web_search || false,
other?.web_search_call_count || 0,
other?.web_search_price || 0,
other?.file_search || false,
other?.file_search_call_count || 0,
other?.file_search_price || 0,
other?.audio_input_seperate_price || false,
other?.audio_input_token_count || 0,
other?.audio_input_price || 0,
other?.image_generation_call || false,
other?.image_generation_call_price || 0,
billingDisplayMode,
);
content = renderModelPrice(logOpts);
}
expandDataLocal.push({
key: t('计费过程'),
......@@ -559,65 +476,15 @@ export const useLogsData = () => {
value: other.reasoning_effort,
});
}
if (other?.billing_mode === 'tiered_expr') {
if (other?.billing_mode === 'tiered_expr' && other?.expr_b64) {
expandDataLocal.push({
key: t('计费方式'),
value: t('阶梯计费'),
});
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('否'),
key: t('计费过程'),
value: renderTieredModelPrice({
...other,
prompt_tokens: logs[i].prompt_tokens,
completion_tokens: logs[i].completion_tokens,
}),
});
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) {
......
......@@ -3421,6 +3421,23 @@
"新年促销": "New Year promo",
"第 {{n}} 组": "Group {{n}}",
"0=周日 1=周一 2=周二 3=周三 4=周四 5=周五 6=周六": "0=Sun 1=Mon 2=Tue 3=Wed 4=Thu 5=Fri 6=Sat",
"1=一月 ... 12=十二月": "1=Jan ... 12=Dec"
"1=一月 ... 12=十二月": "1=Jan ... 12=Dec",
"动态计费": "Dynamic pricing",
"价格根据用量档位和请求条件动态调整": "Price adjusts dynamically based on usage tiers and request conditions",
"分档价格表": "Tiered price table",
"条件乘数": "Condition multipliers",
"分组倍率": "Group ratio",
"将额外乘以上述价格": "will additionally multiply the above prices",
"默认": "Default",
"缓存读取": "Cache read",
"缓存创建": "Cache create",
"缓存创建-1h": "Cache create (1h)",
"见上方动态计费详情": "See dynamic pricing details above",
"分组倍率": "Group ratio",
"含时间条件": "Time rules",
"含请求条件": "Request rules",
"输入": "Input",
"输出": "Output",
"档": "tiers"
}
}
......@@ -3048,6 +3048,23 @@
"新年促销": "新年促销",
"第 {{n}} 组": "第 {{n}} 组",
"0=周日 1=周一 2=周二 3=周三 4=周四 5=周五 6=周六": "0=周日 1=周一 2=周二 3=周三 4=周四 5=周五 6=周六",
"1=一月 ... 12=十二月": "1=一月 ... 12=十二月"
"1=一月 ... 12=十二月": "1=一月 ... 12=十二月",
"动态计费": "动态计费",
"价格根据用量档位和请求条件动态调整": "价格根据用量档位和请求条件动态调整",
"分档价格表": "分档价格表",
"条件乘数": "条件乘数",
"分组倍率": "分组倍率",
"将额外乘以上述价格": "将额外乘以上述价格",
"默认": "默认",
"缓存读取": "缓存读取",
"缓存创建": "缓存创建",
"缓存创建-1h": "缓存创建-1h",
"见上方动态计费详情": "见上方动态计费详情",
"分组倍率": "分组倍率",
"含时间条件": "含时间条件",
"含请求条件": "含请求条件",
"输入": "输入",
"输出": "输出",
"档": "档"
}
}
......@@ -865,6 +865,24 @@ html.dark .with-pastel-balls::before {
height: calc(100vh - 77px);
max-height: calc(100vh - 77px);
}
.semi-input-suffix-text {
font-size: 11px;
padding: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 80px;
}
.semi-input-prefix-text, .semi-input-suffix-text {
margin: 0;
}
.semi-select-arrow {
margin-left: 2px;
margin-right: 2px;
}
}
/* ==================== 模型定价页面布局 ==================== */
......
......@@ -324,7 +324,7 @@ export default function ModelPricingEditor({
gap: 16,
gridTemplateColumns: isMobile
? 'minmax(0, 1fr)'
: 'minmax(360px, 1.1fr) minmax(420px, 1fr)',
: 'minmax(300px, 0.8fr) minmax(480px, 1.2fr)',
}}
>
<Card
......
......@@ -14,17 +14,17 @@ export const MATCH_RANGE = 'range';
export const TIME_FUNCS = ['hour', 'minute', 'weekday', 'month', 'day'];
export const COMMON_TIMEZONES = [
{ value: 'Asia/Shanghai', label: 'CST (UTC+8 北京)' },
{ value: 'Asia/Shanghai', label: 'UTC+8 北京 (Asia/Shanghai)' },
{ value: 'UTC', label: 'UTC' },
{ value: 'America/New_York', label: 'EST (UTC-5 纽约)' },
{ value: 'America/Los_Angeles', label: 'PST (UTC-8 洛杉矶)' },
{ value: 'America/Chicago', label: 'CST (UTC-6 芝加哥)' },
{ value: 'Europe/London', label: 'GMT (UTC+0 伦敦)' },
{ value: 'Europe/Berlin', label: 'CET (UTC+1 柏林)' },
{ value: 'Asia/Tokyo', label: 'JST (UTC+9 东京)' },
{ value: 'Asia/Singapore', label: 'SGT (UTC+8 新加坡)' },
{ value: 'Asia/Seoul', label: 'KST (UTC+9 首尔)' },
{ value: 'Australia/Sydney', label: 'AEST (UTC+10 悉尼)' },
{ value: 'America/New_York', label: 'UTC-5 纽约 (America/New_York)' },
{ value: 'America/Los_Angeles', label: 'UTC-8 洛杉矶 (America/Los_Angeles)' },
{ value: 'America/Chicago', label: 'UTC-6 芝加哥 (America/Chicago)' },
{ value: 'Europe/London', label: 'UTC+0 伦敦 (Europe/London)' },
{ value: 'Europe/Berlin', label: 'UTC+1 柏林 (Europe/Berlin)' },
{ value: 'Asia/Tokyo', label: 'UTC+9 东京 (Asia/Tokyo)' },
{ value: 'Asia/Singapore', label: 'UTC+8 新加坡 (Asia/Singapore)' },
{ value: 'Asia/Seoul', label: 'UTC+9 首尔 (Asia/Seoul)' },
{ value: 'Australia/Sydney', label: 'UTC+10 悉尼 (Australia/Sydney)' },
];
export const NUMERIC_LITERAL_REGEX =
......
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useEffect, useMemo, useState } from 'react';
import { API, showError, showSuccess } from '../../../../helpers';
import {
......@@ -859,7 +877,7 @@ export function useModelPricingEditorState({
upsertModel(selectedModel.name, (model) => {
const next = { ...model, billingMode: value };
if (value === 'tiered_expr' && !model.billingExpr) {
next.billingExpr = 'tier("default", p * 0 + c * 0)';
next.billingExpr = 'tier("base", p * 0 + c * 0)';
}
return next;
});
......
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