From f0589cc478b976154802c1389e8bf73958318e5f Mon Sep 17 00:00:00 2001 From: CaIon Date: Mon, 16 Mar 2026 18:57:14 +0800 Subject: [PATCH] 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. --- model/pricing.go | 9 + pkg/billingexpr/billingexpr_test.go | 52 +++ pkg/billingexpr/compile.go | 6 + pkg/billingexpr/run.go | 6 + pkg/billingexpr/types.go | 7 +- relay/compatible_handler.go | 70 +-- service/log_info_generate.go | 41 +- service/quota.go | 96 ++-- .../modal/ModelDetailSideSheet.jsx | 7 + .../components/DynamicPricingBreakdown.jsx | 293 +++++++++++++ .../modal/components/ModelPricingTable.jsx | 55 ++- .../view/card/PricingCardView.jsx | 7 +- .../table/usage-logs/UsageLogsColumnDefs.jsx | 84 +--- web/src/helpers/render.jsx | 406 ++++++++++++----- web/src/helpers/utils.jsx | 101 ++++- web/src/hooks/usage-logs/useUsageLogsData.jsx | 185 ++------ web/src/i18n/locales/en.json | 19 +- web/src/i18n/locales/zh-CN.json | 19 +- web/src/index.css | 18 + .../Ratio/components/ModelPricingEditor.jsx | 2 +- .../Ratio/components/TieredPricingEditor.jsx | 409 +++++++++++------- .../Ratio/components/requestRuleExpr.js | 20 +- .../Ratio/hooks/useModelPricingEditorState.js | 20 +- 23 files changed, 1237 insertions(+), 695 deletions(-) create mode 100644 web/src/components/table/model-pricing/modal/components/DynamicPricingBreakdown.jsx diff --git a/model/pricing.go b/model/pricing.go index 54ae9845..0fee7cae 100644 --- a/model/pricing.go +++ b/model/pricing.go @@ -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) } diff --git a/pkg/billingexpr/billingexpr_test.go b/pkg/billingexpr/billingexpr_test.go index 34ec0fc0..0ca47ab6 100644 --- a/pkg/billingexpr/billingexpr_test.go +++ b/pkg/billingexpr/billingexpr_test.go @@ -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) + } +} diff --git a/pkg/billingexpr/compile.go b/pkg/billingexpr/compile.go index 4a8b61e9..edb77217 100644 --- a/pkg/billingexpr/compile.go +++ b/pkg/billingexpr/compile.go @@ -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 }, diff --git a/pkg/billingexpr/run.go b/pkg/billingexpr/run.go index 267c5af2..de641766 100644 --- a/pkg/billingexpr/run.go +++ b/pkg/billingexpr/run.go @@ -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 diff --git a/pkg/billingexpr/types.go b/pkg/billingexpr/types.go index 193f82b4..dd626aae 100644 --- a/pkg/billingexpr/types.go +++ b/pkg/billingexpr/types.go @@ -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 diff --git a/relay/compatible_handler.go b/relay/compatible_handler.go index 392677af..63a9d2ee 100644 --- a/relay/compatible_handler.go +++ b/relay/compatible_handler.go @@ -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, - }) -} diff --git a/service/log_info_generate.go b/service/log_info_generate.go index 0737a911..6d4e021c 100644 --- a/service/log_info_generate.go +++ b/service/log_info_generate.go @@ -1,6 +1,7 @@ package service import ( + "encoding/base64" "strings" "github.com/QuantumNous/new-api/common" @@ -216,41 +217,17 @@ func GenerateMjOtherInfo(relayInfo *relaycommon.RelayInfo, priceData types.Price return other } -func GenerateTieredOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, result *billingexpr.TieredResult) map[string]interface{} { - other := make(map[string]interface{}) - other["billing_mode"] = "tiered_expr" - +// InjectTieredBillingInfo overlays tiered billing fields onto an existing +// module-specific other map. Call this after GenerateTextOtherInfo / +// GenerateClaudeOtherInfo / etc. when the request used tiered_expr billing. +func InjectTieredBillingInfo(other map[string]interface{}, relayInfo *relaycommon.RelayInfo, result *billingexpr.TieredResult) { snap := relayInfo.TieredBillingSnapshot - if snap != nil { - other["group_ratio"] = snap.GroupRatio - other["expr_hash"] = snap.ExprHash - other["estimated_prompt_tokens"] = snap.EstimatedPromptTokens - other["estimated_completion_tokens"] = snap.EstimatedCompletionTokens - other["estimated_quota_before_group"] = snap.EstimatedQuotaBeforeGroup - other["estimated_quota_after_group"] = snap.EstimatedQuotaAfterGroup - other["estimated_tier"] = snap.EstimatedTier + if snap == nil { + return } - + other["billing_mode"] = "tiered_expr" + other["expr_b64"] = base64.StdEncoding.EncodeToString([]byte(snap.ExprString)) if result != nil { - other["actual_quota_before_group"] = result.ActualQuotaBeforeGroup - other["actual_quota_after_group"] = result.ActualQuotaAfterGroup other["matched_tier"] = result.MatchedTier - other["crossed_tier"] = result.CrossedTier } - - other["frt"] = float64(relayInfo.FirstResponseTime.UnixMilli() - relayInfo.StartTime.UnixMilli()) - if relayInfo.IsModelMapped { - other["is_model_mapped"] = true - other["upstream_model_name"] = relayInfo.UpstreamModelName - } - - adminInfo := make(map[string]interface{}) - adminInfo["use_channel"] = ctx.GetStringSlice("use_channel") - AppendChannelAffinityAdminInfo(ctx, adminInfo) - other["admin_info"] = adminInfo - - appendRequestPath(ctx, relayInfo, other) - appendRequestConversionChain(relayInfo, other) - appendBillingInfo(relayInfo, other) - return other } diff --git a/service/quota.go b/service/quota.go index eafec351..4341898c 100644 --- a/service/quota.go +++ b/service/quota.go @@ -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, - }) -} diff --git a/web/src/components/table/model-pricing/modal/ModelDetailSideSheet.jsx b/web/src/components/table/model-pricing/modal/ModelDetailSideSheet.jsx index d547b7f4..06479cb8 100644 --- a/web/src/components/table/model-pricing/modal/ModelDetailSideSheet.jsx +++ b/web/src/components/table/model-pricing/modal/ModelDetailSideSheet.jsx @@ -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 && ( + + )} . + +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 ( + +
+ + + + {t('动态计费')} +
+
+ {billingExpr} +
+
+ ); + } + + const tierColumns = [ + { + title: t('档位'), + dataIndex: 'label', + render: (text, record) => ( +
+ {text || t('默认')} + {record.condSummary && ( +
{record.condSummary}
+ )} +
+ ), + }, + { + title: `${t('输入价格')} (${PRICE_SUFFIX})`, + dataIndex: 'inputPrice', + render: (v) => ${v.toFixed(4)}, + }, + { + title: `${t('输出价格')} (${PRICE_SUFFIX})`, + dataIndex: 'outputPrice', + render: (v) => ${v.toFixed(4)}, + }, + ]; + + 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 ? ${v.toFixed(4)} : '-', + }); + } + if (hasCacheCreate) { + tierColumns.push({ + title: `${t('缓存创建')} (${PRICE_SUFFIX})`, + dataIndex: 'cacheCreatePrice', + render: (v) => v != null ? ${v.toFixed(4)} : '-', + }); + } + if (hasCache1h) { + tierColumns.push({ + title: `${t('缓存创建-1h')} (${PRICE_SUFFIX})`, + dataIndex: 'cacheCreate1hPrice', + render: (v) => v != null ? ${v.toFixed(4)} : '-', + }); + } + + 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 ( + +
+ + + +
+ {t('动态计费')} +
+ {t('价格根据用量档位和请求条件动态调整')} +
+
+
+ + {hasTiers && ( +
+ + {t('分档价格表')} + + + + )} + + {hasRules && ( +
+ + {t('条件乘数')} + + {ruleGroups.map((group, gi) => ( +
+ {describeGroup(group, t)} + {group.multiplier}x +
+ ))} +
+ )} + + + ); +} diff --git a/web/src/components/table/model-pricing/modal/components/ModelPricingTable.jsx b/web/src/components/table/model-pricing/modal/components/ModelPricingTable.jsx index b2064609..1427b9b9 100644 --- a/web/src/components/table/model-pricing/modal/components/ModelPricingTable.jsx +++ b/web/src/components/table/model-pricing/modal/components/ModelPricingTable.jsx @@ -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) => ( - + {text}x ), }); } - // 添加计费类型列 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 ( {text || '-'} @@ -126,18 +130,27 @@ const ModelPricingTable = ({ columns.push({ title: siteDisplayType === 'TOKENS' ? t('计费摘要') : t('价格摘要'), dataIndex: 'priceItems', - render: (items) => ( -
- {items.map((item) => ( -
-
- {item.label} {item.value} + render: (items) => { + if (items.length === 1 && items[0].isDynamic) { + return ( + + {t('见上方动态计费详情')} + + ); + } + return ( +
+ {items.map((item) => ( +
+
+ {item.label} {item.value} +
+
{item.suffix}
-
{item.suffix}
-
- ))} -
- ), + ))} +
+ ); + }, }); return ( diff --git a/web/src/components/table/model-pricing/view/card/PricingCardView.jsx b/web/src/components/table/model-pricing/view/card/PricingCardView.jsx index 477da259..77aa1a39 100644 --- a/web/src/components/table/model-pricing/view/card/PricingCardView.jsx +++ b/web/src/components/table/model-pricing/view/card/PricingCardView.jsx @@ -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}
- {formatPriceInfo(priceData, t, siteDisplayType)} + {priceData.isDynamicPricing ? ( + formatDynamicPriceSummary(priceData.billingExpr, t) + ) : ( + formatPriceInfo(priceData, t, siteDisplayType) + )}
diff --git a/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx b/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx index 6f5021b4..8785927d 100644 --- a/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx +++ b/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx @@ -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' }), }; } diff --git a/web/src/helpers/render.jsx b/web/src/helpers/render.jsx index 28da657f..ad7daadd 100644 --- a/web/src/helpers/render.jsx +++ b/web/src/helpers/render.jsx @@ -1620,37 +1620,38 @@ function renderPriceSimpleCore({ return result; } -export function renderModelPrice( - inputTokens, - completionTokens, - modelRatio, - modelPrice = -1, - completionRatio, - groupRatio, - user_group_ratio, - cacheTokens = 0, - cacheRatio = 1.0, - image = false, - imageRatio = 1.0, - imageOutputTokens = 0, - webSearch = false, - webSearchCallCount = 0, - webSearchPrice = 0, - fileSearch = false, - fileSearchCallCount = 0, - fileSearchPrice = 0, - audioInputSeperatePrice = false, - audioInputTokens = 0, - audioInputPrice = 0, - imageGenerationCall = false, - imageGenerationCallPrice = 0, - displayMode = 'price', -) { +export function renderModelPrice(opts) { + const { + prompt_tokens: inputTokens = 0, + completion_tokens: completionTokens = 0, + model_ratio: modelRatio = 0, + model_price: modelPrice = -1, + completion_ratio: completionRatio, + group_ratio: _groupRatio, + user_group_ratio, + cache_tokens: cacheTokens = 0, + cache_ratio: cacheRatio = 1.0, + image = false, + image_ratio: imageRatio = 1.0, + image_output: imageOutputTokens = 0, + web_search: webSearch = false, + web_search_call_count: webSearchCallCount = 0, + web_search_price: webSearchPrice = 0, + file_search: fileSearch = false, + file_search_call_count: fileSearchCallCount = 0, + file_search_price: fileSearchPrice = 0, + audio_input_seperate_price: audioInputSeperatePrice = false, + audio_input_token_count: audioInputTokens = 0, + audio_input_price: audioInputPrice = 0, + image_generation_call: imageGenerationCall = false, + image_generation_call_price: imageGenerationCallPrice = 0, + displayMode = 'price', + } = opts; const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio( - groupRatio, + _groupRatio, user_group_ratio, ); - groupRatio = effectiveGroupRatio; + let groupRatio = effectiveGroupRatio; const { symbol, rate } = getCurrencyConfig(); @@ -2078,21 +2079,22 @@ export function renderModelPrice( ]); } -export function renderLogContent( - modelRatio, - completionRatio, - modelPrice = -1, - groupRatio, - user_group_ratio, - cacheRatio = 1.0, - image = false, - imageRatio = 1.0, - webSearch = false, - webSearchCallCount = 0, - fileSearch = false, - fileSearchCallCount = 0, - displayMode = 'price', -) { +export function renderLogContent(opts) { + const { + model_ratio: modelRatio, + completion_ratio: completionRatio, + model_price: modelPrice = -1, + group_ratio: groupRatio, + user_group_ratio, + cache_ratio: cacheRatio = 1.0, + image = false, + image_ratio: imageRatio = 1.0, + web_search: webSearch = false, + web_search_call_count: webSearchCallCount = 0, + file_search: fileSearch = false, + file_search_call_count: fileSearchCallCount = 0, + displayMode = 'price', + } = opts; const { ratio, label: ratioLabel, @@ -2208,26 +2210,193 @@ export function renderLogContent( } } -export function renderModelPriceSimple( - modelRatio, - modelPrice = -1, - groupRatio, - user_group_ratio, - cacheTokens = 0, - cacheRatio = 1.0, - cacheCreationTokens = 0, - cacheCreationRatio = 1.0, - cacheCreationTokens5m = 0, - cacheCreationRatio5m = 1.0, - cacheCreationTokens1h = 0, - cacheCreationRatio1h = 1.0, - image = false, - imageRatio = 1.0, - isSystemPromptOverride = false, - provider = 'openai', - displayMode = 'price', - outputMode = 'text', -) { +function parseTiersFromExpr(exprStr) { + if (!exprStr) return []; + try { + const cacheVars = ['cr', 'cc', 'cc1h']; + const optCache = cacheVars.map((v) => `(?:\\s*\\+\\s*${v}\\s*\\*\\s*([\\d.eE+-]+))?`).join(''); + const bodyPat = `p\\s*\\*\\s*([\\d.eE+-]+)\\s*\\+\\s*c\\s*\\*\\s*([\\d.eE+-]+)${optCache}`; + const tierRe = new RegExp(`tier\\("([^"]*)",\\s*${bodyPat}\\)`, 'g'); + const tiers = []; + let m; + while ((m = tierRe.exec(exprStr)) !== null) { + tiers.push({ + label: m[1], + inputPrice: Number(m[2]) * 2, + outputPrice: Number(m[3]) * 2, + cacheReadPrice: m[4] ? Number(m[4]) * 2 : 0, + cacheCreatePrice: m[5] ? Number(m[5]) * 2 : 0, + cacheCreate1hPrice: m[6] ? Number(m[6]) * 2 : 0, + }); + } + return tiers; + } catch { + return []; + } +} + +export function renderTieredModelPrice(opts) { + const { + prompt_tokens: inputTokens = 0, + completion_tokens: completionTokens = 0, + expr_b64: exprB64, + matched_tier: matchedTier, + group_ratio: groupRatio, + cache_tokens: cacheTokens = 0, + cache_creation_tokens: cacheCreationTokens = 0, + cache_creation_tokens_5m: cacheCreationTokens5m = 0, + cache_creation_tokens_1h: cacheCreationTokens1h = 0, + } = opts; + let exprStr = ''; + try { exprStr = atob(exprB64); } catch { /* ignore */ } + const tiers = parseTiersFromExpr(exprStr); + if (tiers.length === 0) { + return i18next.t('阶梯计费(表达式解析失败)'); + } + + const tier = tiers.find((t) => t.label === matchedTier) || tiers[0]; + const { symbol, rate } = getCurrencyConfig(); + const gr = groupRatio || 1; + + const inputCost = (inputTokens / 1000000) * tier.inputPrice; + const outputCost = (completionTokens / 1000000) * tier.outputPrice; + const cacheReadCost = (cacheTokens / 1000000) * tier.cacheReadPrice; + const hasSplitCacheCreation = cacheCreationTokens5m > 0 || cacheCreationTokens1h > 0; + let cacheCreateCost = 0; + if (hasSplitCacheCreation) { + cacheCreateCost = (cacheCreationTokens5m / 1000000) * tier.cacheCreatePrice + + (cacheCreationTokens1h / 1000000) * tier.cacheCreate1hPrice; + } else if (cacheCreationTokens > 0) { + cacheCreateCost = (cacheCreationTokens / 1000000) * tier.cacheCreatePrice; + } + const totalBeforeGroup = inputCost + outputCost + cacheReadCost + cacheCreateCost; + const total = totalBeforeGroup * gr; + + const lines = [ + buildBillingText('命中档位:{{tier}}', { tier: matchedTier || tier.label }), + buildBillingPriceText('输入价格:{{symbol}}{{price}} / 1M tokens', { symbol, usdAmount: tier.inputPrice, rate }), + buildBillingPriceText('输出价格:{{symbol}}{{price}} / 1M tokens', { symbol, usdAmount: tier.outputPrice, rate }), + cacheTokens > 0 && tier.cacheReadPrice > 0 + ? buildBillingPriceText('缓存读取价格:{{symbol}}{{price}} / 1M tokens', { symbol, usdAmount: tier.cacheReadPrice, rate }) + : null, + hasSplitCacheCreation && cacheCreationTokens5m > 0 && tier.cacheCreatePrice > 0 + ? buildBillingPriceText('5m缓存创建价格:{{symbol}}{{price}} / 1M tokens', { symbol, usdAmount: tier.cacheCreatePrice, rate }) + : null, + hasSplitCacheCreation && cacheCreationTokens1h > 0 && tier.cacheCreate1hPrice > 0 + ? buildBillingPriceText('1h缓存创建价格:{{symbol}}{{price}} / 1M tokens', { symbol, usdAmount: tier.cacheCreate1hPrice, rate }) + : null, + !hasSplitCacheCreation && cacheCreationTokens > 0 && tier.cacheCreatePrice > 0 + ? buildBillingPriceText('缓存创建价格:{{symbol}}{{price}} / 1M tokens', { symbol, usdAmount: tier.cacheCreatePrice, rate }) + : null, + buildBillingText( + '(输入 {{input}} tokens / 1M tokens * {{symbol}}{{inputPrice}} + 输出 {{output}} tokens / 1M tokens * {{symbol}}{{outputPrice}}) * 分组倍率 {{ratio}} = {{symbol}}{{total}}', + { + input: inputTokens, output: completionTokens, symbol, + inputPrice: formatBillingDisplayPrice(tier.inputPrice, rate), + outputPrice: formatBillingDisplayPrice(tier.outputPrice, rate), + ratio: gr, total: formatBillingDisplayPrice(total, rate), + }, + ), + ]; + + return renderBillingArticle(lines); +} + +export function renderTieredModelPriceSimple(opts) { + const { + expr_b64: exprB64, + matched_tier: matchedTier, + group_ratio: groupRatio, + user_group_ratio, + cache_tokens: cacheTokens = 0, + cache_creation_tokens_5m: cacheCreationTokens5m = 0, + cache_creation_tokens_1h: cacheCreationTokens1h = 0, + cache_creation_tokens: cacheCreationTokens = 0, + displayMode = 'price', + outputMode = 'segments', + } = opts; + let exprStr = ''; + try { exprStr = atob(exprB64); } catch { /* ignore */ } + const tiers = parseTiersFromExpr(exprStr); + const tier = tiers.find((t) => t.label === matchedTier) || tiers[0]; + + if (outputMode === 'segments') { + const segments = [ + { + tone: 'primary', + text: getGroupRatioText(groupRatio, user_group_ratio), + }, + ]; + + if (tier && isPriceDisplayMode(displayMode)) { + segments.push({ + tone: 'secondary', + text: i18next.t('输入 {{price}} / 1M tokens', { + price: formatCompactDisplayPrice(tier.inputPrice), + }), + }); + if (cacheTokens > 0 && tier.cacheReadPrice > 0) { + segments.push({ + tone: 'secondary', + text: i18next.t('缓存读 {{price}} / 1M tokens', { + price: formatCompactDisplayPrice(tier.cacheReadPrice), + }), + }); + } + const hasSplitCacheCreation = cacheCreationTokens5m > 0 || cacheCreationTokens1h > 0; + if (hasSplitCacheCreation && cacheCreationTokens5m > 0 && tier.cacheCreatePrice > 0) { + segments.push({ + tone: 'secondary', + text: i18next.t('5m缓存创建 {{price}} / 1M tokens', { + price: formatCompactDisplayPrice(tier.cacheCreatePrice), + }), + }); + } + if (hasSplitCacheCreation && cacheCreationTokens1h > 0 && tier.cacheCreate1hPrice > 0) { + segments.push({ + tone: 'secondary', + text: i18next.t('1h缓存创建 {{price}} / 1M tokens', { + price: formatCompactDisplayPrice(tier.cacheCreate1hPrice), + }), + }); + } + if (!hasSplitCacheCreation && cacheCreationTokens > 0 && tier.cacheCreatePrice > 0) { + segments.push({ + tone: 'secondary', + text: i18next.t('缓存创建 {{price}} / 1M tokens', { + price: formatCompactDisplayPrice(tier.cacheCreatePrice), + }), + }); + } + } + + return segments; + } + + return []; +} + +export function renderModelPriceSimple(opts) { + const { + model_ratio: modelRatio, + model_price: modelPrice = -1, + group_ratio: groupRatio, + user_group_ratio, + cache_tokens: cacheTokens = 0, + cache_ratio: cacheRatio = 1.0, + cache_creation_tokens: cacheCreationTokens = 0, + cache_creation_ratio: cacheCreationRatio = 1.0, + cache_creation_tokens_5m: cacheCreationTokens5m = 0, + cache_creation_ratio_5m: cacheCreationRatio5m = 1.0, + cache_creation_tokens_1h: cacheCreationTokens1h = 0, + cache_creation_ratio_1h: cacheCreationRatio1h = 1.0, + image = false, + image_ratio: imageRatio = 1.0, + is_system_prompt_overwritten: isSystemPromptOverride = false, + provider = 'openai', + displayMode = 'price', + outputMode = 'text', + } = opts; return renderPriceSimpleCore({ modelRatio, modelPrice, @@ -2249,27 +2418,28 @@ export function renderModelPriceSimple( }); } -export function renderAudioModelPrice( - inputTokens, - completionTokens, - modelRatio, - modelPrice = -1, - completionRatio, - audioInputTokens, - audioCompletionTokens, - audioRatio, - audioCompletionRatio, - groupRatio, - user_group_ratio, - cacheTokens = 0, - cacheRatio = 1.0, - displayMode = 'price', -) { +export function renderAudioModelPrice(opts) { + const { + prompt_tokens: inputTokens = 0, + completion_tokens: completionTokens = 0, + model_ratio: modelRatio = 0, + model_price: modelPrice = -1, + completion_ratio: completionRatio, + audio_input: audioInputTokens = 0, + audio_output: audioCompletionTokens = 0, + audio_ratio: audioRatio, + audio_completion_ratio: audioCompletionRatio, + group_ratio: _groupRatio, + user_group_ratio, + cache_tokens: cacheTokens = 0, + cache_ratio: cacheRatio = 1.0, + displayMode = 'price', + } = opts; const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio( - groupRatio, + _groupRatio, user_group_ratio, ); - groupRatio = effectiveGroupRatio; + let groupRatio = effectiveGroupRatio; // 获取货币配置 const { symbol, rate } = getCurrencyConfig(); @@ -2535,29 +2705,30 @@ export function renderQuotaWithPrompt(quota, digits) { return ''; } -export function renderClaudeModelPrice( - inputTokens, - completionTokens, - modelRatio, - modelPrice = -1, - completionRatio, - groupRatio, - user_group_ratio, - cacheTokens = 0, - cacheRatio = 1.0, - cacheCreationTokens = 0, - cacheCreationRatio = 1.0, - cacheCreationTokens5m = 0, - cacheCreationRatio5m = 1.0, - cacheCreationTokens1h = 0, - cacheCreationRatio1h = 1.0, - displayMode = 'price', -) { +export function renderClaudeModelPrice(opts) { + const { + prompt_tokens: inputTokens = 0, + completion_tokens: completionTokens = 0, + model_ratio: modelRatio = 0, + model_price: modelPrice = -1, + completion_ratio: completionRatio, + group_ratio: _groupRatio, + user_group_ratio, + cache_tokens: cacheTokens = 0, + cache_ratio: cacheRatio = 1.0, + cache_creation_tokens: cacheCreationTokens = 0, + cache_creation_ratio: cacheCreationRatio = 1.0, + cache_creation_tokens_5m: cacheCreationTokens5m = 0, + cache_creation_ratio_5m: cacheCreationRatio5m = 1.0, + cache_creation_tokens_1h: cacheCreationTokens1h = 0, + cache_creation_ratio_1h: cacheCreationRatio1h = 1.0, + displayMode = 'price', + } = opts; const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio( - groupRatio, + _groupRatio, user_group_ratio, ); - groupRatio = effectiveGroupRatio; + let groupRatio = effectiveGroupRatio; // 获取货币配置 const { symbol, rate } = getCurrencyConfig(); @@ -2944,25 +3115,26 @@ export function renderClaudeModelPrice( ]); } -export function renderClaudeLogContent( - modelRatio, - completionRatio, - modelPrice = -1, - groupRatio, - user_group_ratio, - cacheRatio = 1.0, - cacheCreationRatio = 1.0, - cacheCreationTokens5m = 0, - cacheCreationRatio5m = 1.0, - cacheCreationTokens1h = 0, - cacheCreationRatio1h = 1.0, - displayMode = 'price', -) { +export function renderClaudeLogContent(opts) { + const { + model_ratio: modelRatio, + completion_ratio: completionRatio, + model_price: modelPrice = -1, + group_ratio: _groupRatio, + user_group_ratio, + cache_ratio: cacheRatio = 1.0, + cache_creation_ratio: cacheCreationRatio = 1.0, + cache_creation_tokens_5m: cacheCreationTokens5m = 0, + cache_creation_ratio_5m: cacheCreationRatio5m = 1.0, + cache_creation_tokens_1h: cacheCreationTokens1h = 0, + cache_creation_ratio_1h: cacheCreationRatio1h = 1.0, + displayMode = 'price', + } = opts; const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio( - groupRatio, + _groupRatio, user_group_ratio, ); - groupRatio = effectiveGroupRatio; + let groupRatio = effectiveGroupRatio; // 获取货币配置 const { symbol, rate } = getCurrencyConfig(); diff --git a/web/src/helpers/utils.jsx b/web/src/helpers/utils.jsx index 435a11ed..6f68528a 100644 --- a/web/src/helpers/utils.jsx +++ b/web/src/helpers/utils.jsx @@ -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 {t('动态计费')}; + + 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 && ( + <> + + {t('输入价格')} ${(Number(firstTierMatch[1]) * 2).toFixed(4)}{unitSuffix} + + + {t('输出价格')} ${(Number(firstTierMatch[2]) * 2).toFixed(4)}{unitSuffix} + + {firstTierMatch[3] && ( + + {t('缓存读取价格')} ${(Number(firstTierMatch[3]) * 2).toFixed(4)}{unitSuffix} + + )} + {firstTierMatch[4] && ( + + {t('缓存创建价格')} ${(Number(firstTierMatch[4]) * 2).toFixed(4)}{unitSuffix} + + )} + + )} + + + {t('动态计费')} + + {tags.map((tag) => ( + + {tag} + + ))} + + + ); +}; + // 格式化价格信息(用于卡片视图) export const formatPriceInfo = (priceData, t, quotaDisplayType = 'USD') => { const items = getModelPriceItems(priceData, t, quotaDisplayType); diff --git a/web/src/hooks/usage-logs/useUsageLogsData.jsx b/web/src/hooks/usage-logs/useUsageLogsData.jsx index 84019a22..b961d7a6 100644 --- a/web/src/hooks/usage-logs/useUsageLogsData.jsx +++ b/web/src/hooks/usage-logs/useUsageLogsData.jsx @@ -36,6 +36,7 @@ import { renderAudioModelPrice, renderClaudeModelPrice, renderModelPrice, + renderTieredModelPrice, } from '../../helpers'; import { ITEMS_PER_PAGE } from '../../constants'; import { useTableCompactMode } from '../common/useTableCompactMode'; @@ -407,43 +408,14 @@ export const useLogsData = () => { }); } if (logs[i].type === 2) { - expandDataLocal.push({ - key: t('日志详情'), - value: other?.claude - ? renderClaudeLogContent( - other?.model_ratio, - other.completion_ratio, - other.model_price, - other.group_ratio, - other?.user_group_ratio, - other.cache_ratio || 1.0, - other.cache_creation_ratio || 1.0, - other.cache_creation_tokens_5m || 0, - other.cache_creation_ratio_5m || - other.cache_creation_ratio || - 1.0, - other.cache_creation_tokens_1h || 0, - other.cache_creation_ratio_1h || - other.cache_creation_ratio || - 1.0, - billingDisplayMode, - ) - : renderLogContent( - other?.model_ratio, - other.completion_ratio, - other.model_price, - other.group_ratio, - other?.user_group_ratio, - other.cache_ratio || 1.0, - false, - 1.0, - other.web_search || false, - other.web_search_call_count || 0, - other.file_search || false, - other.file_search_call_count || 0, - billingDisplayMode, - ), - }); + if (other?.billing_mode !== 'tiered_expr') { + expandDataLocal.push({ + key: t('日志详情'), + value: other?.claude + ? renderClaudeLogContent({ ...other, displayMode: billingDisplayMode }) + : renderLogContent({ ...other, displayMode: billingDisplayMode }), + }); + } if (logs[i]?.content) { expandDataLocal.push({ key: t('其他详情'), @@ -479,74 +451,19 @@ export const useLogsData = () => { Boolean(other?.violation_fee_marker); let content = ''; - if (!isViolationFeeLog) { + if (!isViolationFeeLog && other?.billing_mode !== 'tiered_expr') { + const logOpts = { + ...other, + prompt_tokens: logs[i].prompt_tokens, + completion_tokens: logs[i].completion_tokens, + displayMode: billingDisplayMode, + }; if (other?.ws || other?.audio) { - content = renderAudioModelPrice( - other?.text_input, - other?.text_output, - other?.model_ratio, - other?.model_price, - other?.completion_ratio, - other?.audio_input, - other?.audio_output, - other?.audio_ratio, - other?.audio_completion_ratio, - other?.group_ratio, - other?.user_group_ratio, - other?.cache_tokens || 0, - other?.cache_ratio || 1.0, - billingDisplayMode, - ); + content = renderAudioModelPrice(logOpts); } else if (other?.claude) { - content = renderClaudeModelPrice( - logs[i].prompt_tokens, - logs[i].completion_tokens, - other.model_ratio, - other.model_price, - other.completion_ratio, - other.group_ratio, - other?.user_group_ratio, - other.cache_tokens || 0, - other.cache_ratio || 1.0, - other.cache_creation_tokens || 0, - other.cache_creation_ratio || 1.0, - other.cache_creation_tokens_5m || 0, - other.cache_creation_ratio_5m || - other.cache_creation_ratio || - 1.0, - other.cache_creation_tokens_1h || 0, - other.cache_creation_ratio_1h || - other.cache_creation_ratio || - 1.0, - billingDisplayMode, - ); + content = renderClaudeModelPrice(logOpts); } else { - content = renderModelPrice( - logs[i].prompt_tokens, - logs[i].completion_tokens, - other?.model_ratio, - other?.model_price, - other?.completion_ratio, - other?.group_ratio, - other?.user_group_ratio, - other?.cache_tokens || 0, - other?.cache_ratio || 1.0, - other?.image || false, - other?.image_ratio || 0, - other?.image_output || 0, - other?.web_search || false, - other?.web_search_call_count || 0, - other?.web_search_price || 0, - other?.file_search || false, - other?.file_search_call_count || 0, - other?.file_search_price || 0, - other?.audio_input_seperate_price || false, - other?.audio_input_token_count || 0, - other?.audio_input_price || 0, - other?.image_generation_call || false, - other?.image_generation_call_price || 0, - billingDisplayMode, - ); + content = renderModelPrice(logOpts); } expandDataLocal.push({ key: t('计费过程'), @@ -559,65 +476,15 @@ export const useLogsData = () => { value: other.reasoning_effort, }); } - if (other?.billing_mode === 'tiered_expr') { + if (other?.billing_mode === 'tiered_expr' && other?.expr_b64) { expandDataLocal.push({ - key: t('计费方式'), - value: t('阶梯计费'), + key: t('计费过程'), + value: renderTieredModelPrice({ + ...other, + prompt_tokens: logs[i].prompt_tokens, + completion_tokens: logs[i].completion_tokens, + }), }); - if (other?.group_ratio !== undefined) { - const gr = other.group_ratio; - expandDataLocal.push({ - key: t('分组倍率'), - value: typeof gr === 'number' ? gr.toFixed(4) : String(gr ?? '-'), - }); - } - if (other?.rule_version !== undefined) { - expandDataLocal.push({ - key: t('规则版本'), - value: String(other.rule_version), - }); - } - if (other?.estimated_env) { - expandDataLocal.push({ - key: t('预估环境'), - value: `prompt=${other.estimated_env.prompt_tokens ?? 0}, completion=${other.estimated_env.completion_tokens ?? 0}`, - }); - } - if (other?.actual_env) { - expandDataLocal.push({ - key: t('实际环境'), - value: `prompt=${other.actual_env.prompt_tokens ?? 0}, completion=${other.actual_env.completion_tokens ?? 0}`, - }); - } - if (other?.estimated_quota_after_group !== undefined) { - expandDataLocal.push({ - key: t('预估额度'), - value: String(other.estimated_quota_after_group), - }); - } - if (other?.actual_quota_after_group !== undefined) { - expandDataLocal.push({ - key: t('实际额度'), - value: String(other.actual_quota_after_group), - }); - } - expandDataLocal.push({ - key: t('跨阶梯'), - value: other?.crossed_tier ? t('是') : t('否'), - }); - if (Array.isArray(other?.breakdown) && other.breakdown.length > 0) { - const breakdownText = other.breakdown.map((item, idx) => - `[${idx}] ${item.token_type} | tokens=${item.tokens_in_tier} | cost=${item.unit_cost} | flat=${item.flat_fee} | sub=${item.subtotal}` - ).join('\n'); - expandDataLocal.push({ - key: t('计费明细'), - value: ( -
- {breakdownText} -
- ), - }); - } } } if (logs[i].type === 6) { diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 583d525c..60002614 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -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" } } diff --git a/web/src/i18n/locales/zh-CN.json b/web/src/i18n/locales/zh-CN.json index ccbdee91..67f60bef 100644 --- a/web/src/i18n/locales/zh-CN.json +++ b/web/src/i18n/locales/zh-CN.json @@ -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", + "见上方动态计费详情": "见上方动态计费详情", + "分组倍率": "分组倍率", + "含时间条件": "含时间条件", + "含请求条件": "含请求条件", + "输入": "输入", + "输出": "输出", + "档": "档" } } diff --git a/web/src/index.css b/web/src/index.css index b01a2a04..2ad04189 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -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; + } } /* ==================== 模型定价页面布局 ==================== */ diff --git a/web/src/pages/Setting/Ratio/components/ModelPricingEditor.jsx b/web/src/pages/Setting/Ratio/components/ModelPricingEditor.jsx index 0a6bb45b..2beafe01 100644 --- a/web/src/pages/Setting/Ratio/components/ModelPricingEditor.jsx +++ b/web/src/pages/Setting/Ratio/components/ModelPricingEditor.jsx @@ -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)', }} > -
+ onChange({ ...cond, var: val })} - style={{ width: 110 }} - > - {VAR_OPTIONS.map((v) => ( - - {v.label} - - ))} - - - onChange({ ...cond, value: val })} - style={{ flex: 1, minWidth: 100 }} - /> -
+ {VAR_OPTIONS.map((v) => ( + + {v.label} + + ))} + + + onChange({ ...cond, value: val })} + /> + + )} + +
+ {visibleGroups.map((g) => ( +
+ + {t(g.group)} + + {g.presets.map((p) => ( + + ))} +
+ ))} +
+ + ); +} + function RawExprEditor({ exprString, onChange, t }) { return (
@@ -843,10 +912,13 @@ function RawExprEditor({ exprString, onChange, t }) { // Cache token inputs for estimator — auto-shown when expression uses cache vars // --------------------------------------------------------------------------- -const CACHE_ESTIMATOR_FIELDS = [ +const EXTRA_ESTIMATOR_FIELDS = [ { var: 'cr', stateKey: 'cacheReadTokens', labelKey: '缓存读取 Token (cr)' }, { var: 'cc', stateKey: 'cacheCreateTokens', labelKey: '缓存创建 Token (cc)' }, { var: 'cc1h', stateKey: 'cacheCreate1hTokens', labelKey: '缓存创建-1小时 (cc1h)' }, + { var: 'img', stateKey: 'imageTokens', labelKey: '图片输入 Token (img)' }, + { var: 'ai', stateKey: 'audioInputTokens', labelKey: '音频输入 Token (ai)' }, + { var: 'ao', stateKey: 'audioOutputTokens', labelKey: '音频补全 Token (ao)' }, ]; function CacheTokenEstimatorInputs({ @@ -854,25 +926,34 @@ function CacheTokenEstimatorInputs({ cacheReadTokens, setCacheReadTokens, cacheCreateTokens, setCacheCreateTokens, cacheCreate1hTokens, setCacheCreate1hTokens, + imageTokens, setImageTokens, + audioInputTokens, setAudioInputTokens, + audioOutputTokens, setAudioOutputTokens, t, }) { const setters = { cacheReadTokens: setCacheReadTokens, cacheCreateTokens: setCacheCreateTokens, cacheCreate1hTokens: setCacheCreate1hTokens, + imageTokens: setImageTokens, + audioInputTokens: setAudioInputTokens, + audioOutputTokens: setAudioOutputTokens, }; const values = { cacheReadTokens, cacheCreateTokens, cacheCreate1hTokens, + imageTokens, + audioInputTokens, + audioOutputTokens, }; - const usesCache = useMemo(() => { + const usesExtra = useMemo(() => { if (!effectiveExpr) return false; - return /\b(cr|cc1h|cc)\b/.test(effectiveExpr); + return /\b(cr|cc1h|cc|img|ai|ao)\b/.test(effectiveExpr); }, [effectiveExpr]); - if (!usesCache) return null; + if (!usesExtra) return null; return (
- {CACHE_ESTIMATOR_FIELDS.map((cf) => ( + {EXTRA_ESTIMATOR_FIELDS.map((cf) => (
{t(cf.labelKey)} @@ -904,7 +985,7 @@ function CacheTokenEstimatorInputs({ // Cost estimator (works with any Expr string) // --------------------------------------------------------------------------- -function evalExprLocally(exprStr, p, c, cr, cc, cc1h) { +function evalExprLocally(exprStr, p, c, cr, cc, cc1h, img, ai, ao) { try { let matchedTier = ''; const tierFn = (name, value) => { @@ -917,6 +998,17 @@ function evalExprLocally(exprStr, p, c, cr, cc, cc1h) { cr: cr || 0, cc: cc || 0, cc1h: cc1h || 0, + img: img || 0, + ai: ai || 0, + ao: ao || 0, + prompt_tokens: p, + completion_tokens: c, + cache_read_tokens: cr || 0, + cache_create_tokens: cc || 0, + cache_create_1h_tokens: cc1h || 0, + image_tokens: img || 0, + audio_input_tokens: ai || 0, + audio_output_tokens: ao || 0, tier: tierFn, max: Math.max, min: Math.min, @@ -995,37 +1087,47 @@ function RuleConditionRow({ cond, onChange, onRemove, t }) { const ph = TIME_FUNC_PLACEHOLDERS[normalized.timeFunc] || ''; const hint = TIME_FUNC_HINTS[normalized.timeFunc] || ''; return ( -
-
+
+
{sourceSelect} - + {removeBtn} +
+ +
onChange({ ...normalized, rangeEnd: value })} />
) : ( - onChange({ ...normalized, value })} /> + onChange({ ...normalized, value })} /> )} - {removeBtn}
{hint && ( - + {t(hint)} )} @@ -1053,20 +1154,27 @@ function RuleConditionRow({ cond, onChange, onRemove, t }) { const showValue = normalized.mode !== MATCH_EXISTS; return ( -
+
{sourceSelect} onChange({ ...normalized, path: value })} - style={{ flex: 1, minWidth: 120 }} /> + {removeBtn}