feat(model):claude模型5m/1h缓存收费

This commit is contained in:
chengqian committed 2026-08-17 17:37:23 +08:00
1 parent f56301560e
commit 922003e758
3 files changed
+123 -53

No files matched your search

+43 -27
View File
@@ -45,8 +45,10 @@ type PricingResult struct {
Currency string
// Cache 直接定价
CacheReadPrice float64 // 缓存读取每 1M token 价格
CacheCreationPrice float64 // 缓存创建每 1M token 价格
CacheReadPrice float64 // 缓存读取每 1M token 价格
CacheCreationPrice float64 // 缓存创建每 1M token 价格(5m TTL 基础价)
CacheCreation5mPrice float64 // 5 分钟缓存创建价格(1.25× 基础价)
CacheCreation1hPrice float64 // 1 小时缓存创建价格(2× 基础价)
// 模型最大输出 token 数(随定价一起缓存,供预扣估算使用,
// 避免 EstimatePreDeductAmount 每请求单独查一次 mdl_models)。
@@ -252,21 +254,23 @@ func GetModelPrice(ctx context.Context, tenantID int64, modelName string) (*Pric
modelMultiplier := 1.0
result := &PricingResult{
InputPrice: inputPrice,
OutputPrice: outputPrice,
BaseInputPrice: baseInputPrice,
BaseOutputPrice: baseOutputPrice,
BillingMode: billingMode,
BillingSource: billingSource,
PerRequestPrice: perRequestPrice,
DiscountRatio: discountRatio,
TenantMultiplier: tenantMultiplier,
ModelMultiplier: modelMultiplier,
Currency: "USD",
CacheReadPrice: cacheReadPrice,
CacheCreationPrice: cacheCreationPrice,
MaxOutputTokens: model.MaxOutputTokens,
CustomTiers: customTiers,
InputPrice: inputPrice,
OutputPrice: outputPrice,
BaseInputPrice: baseInputPrice,
BaseOutputPrice: baseOutputPrice,
BillingMode: billingMode,
BillingSource: billingSource,
PerRequestPrice: perRequestPrice,
DiscountRatio: discountRatio,
TenantMultiplier: tenantMultiplier,
ModelMultiplier: modelMultiplier,
Currency: "USD",
CacheReadPrice: cacheReadPrice,
CacheCreationPrice: cacheCreationPrice,
CacheCreation5mPrice: cacheCreationPrice, // 5m TTL = cache_creation_price 基础价(对应官方 1.25× 输入价)
CacheCreation1hPrice: cacheCreationPrice * 1.6, // 1h TTL = 1.6× 基础价(因为 2.0÷1.25=1.6,对应官方 2× 输入价)
MaxOutputTokens: model.MaxOutputTokens,
CustomTiers: customTiers,
}
modelPriceCache.Set(ctx, cacheKey, result)
@@ -287,27 +291,27 @@ func CalculateCost(ctx context.Context, tenantID int64, modelName string, inputT
// computeCost 纯计算:根据定价结果和 token 用量计算费用明细。
// 提取为独立函数以便单元测试,不依赖数据库或缓存。
func computeCost(pricing *PricingResult, inputTokens, outputTokens int, usage *rcommon.Usage) *CostBreakdown {
inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens := resolveTokenCounts(pricing, inputTokens, outputTokens, usage)
baseInputTokens, outputTokens, cacheReadTokens, cacheCreation5mTokens, cacheCreation1hTokens := resolveTokenCounts(pricing, inputTokens, outputTokens, usage)
// 按次计费:直接返回单价
if pricing.BillingMode == "per_request" {
return &CostBreakdown{
BaseCost: pricing.PerRequestPrice,
TotalCost: pricing.PerRequestPrice,
InputTokens: inputTokens,
InputTokens: baseInputTokens,
OutputTokens: outputTokens,
BillingMode: pricing.BillingMode,
PerRequestPrice: pricing.PerRequestPrice,
DiscountRatio: pricing.DiscountRatio,
TenantMultiplier: pricing.TenantMultiplier,
Currency: pricing.Currency,
CacheCreationTokens: cacheCreationTokens,
CacheCreationTokens: cacheCreation5mTokens + cacheCreation1hTokens,
CacheReadTokens: cacheReadTokens,
}
}
// 基础输入费用(已改为 decimal)
baseInputCostD := computeInputCost(pricing, inputTokens)
baseInputCostD := computeInputCost(pricing, baseInputTokens)
// 输出费用(已改为 decimal)
outputCostD := computeOutputCost(pricing, outputTokens)
@@ -318,7 +322,11 @@ func computeCost(pricing *PricingResult, inputTokens, outputTokens int, usage *r
mul := NewFromFloat(pricing.TenantMultiplier)
cacheReadCostD := decimal.NewFromInt(int64(cacheReadTokens)).Div(million).Mul(NewFromFloat(pricing.CacheReadPrice))
cacheCreationCostD := decimal.NewFromInt(int64(cacheCreationTokens)).Div(million).Mul(NewFromFloat(pricing.CacheCreationPrice))
// 缓存创建按 TTL 分别计价:5m 按 1.25×,1h 按 2×
cacheCreation5mCostD := decimal.NewFromInt(int64(cacheCreation5mTokens)).Div(million).Mul(NewFromFloat(pricing.CacheCreation5mPrice))
cacheCreation1hCostD := decimal.NewFromInt(int64(cacheCreation1hTokens)).Div(million).Mul(NewFromFloat(pricing.CacheCreation1hPrice))
cacheCreationCostD := cacheCreation5mCostD.Add(cacheCreation1hCostD)
// 总费用 = (基础输入 + 输出 + cache各项) × 租户倍率
subtotalD := baseInputCostD.Add(outputCostD).Add(cacheReadCostD).Add(cacheCreationCostD)
@@ -329,14 +337,14 @@ func computeCost(pricing *PricingResult, inputTokens, outputTokens int, usage *r
InputCost: InexactFloat64(RoundMoney(baseInputCostD.Mul(mul))),
OutputCost: InexactFloat64(RoundMoney(outputCostD.Mul(mul))),
TotalCost: InexactFloat64(RoundMoney(totalCostD)),
InputTokens: inputTokens,
InputTokens: baseInputTokens,
OutputTokens: outputTokens,
BillingMode: pricing.BillingMode,
PerRequestPrice: pricing.PerRequestPrice,
DiscountRatio: pricing.DiscountRatio,
TenantMultiplier: pricing.TenantMultiplier,
Currency: pricing.Currency,
CacheCreationTokens: cacheCreationTokens,
CacheCreationTokens: cacheCreation5mTokens + cacheCreation1hTokens,
CacheReadTokens: cacheReadTokens,
CacheCreationCost: InexactFloat64(RoundMoney(cacheCreationCostD.Mul(mul))),
CacheReadCost: InexactFloat64(RoundMoney(cacheReadCostD.Mul(mul))),
@@ -345,17 +353,25 @@ func computeCost(pricing *PricingResult, inputTokens, outputTokens int, usage *r
// resolveTokenCounts 根据 usage 信息解析最终的 token 计数。
// 处理 cacheIncludedInPrompt 逻辑:如果 PromptTokens 包含 cache tokens,则扣减以避免重复计费。
func resolveTokenCounts(pricing *PricingResult, inputTokens, outputTokens int, usage *rcommon.Usage) (baseInput, output, cacheRead, cacheCreation int) {
// 返回:baseInput, output, cacheRead, cacheCreation5m, cacheCreation1h
func resolveTokenCounts(pricing *PricingResult, inputTokens, outputTokens int, usage *rcommon.Usage) (baseInput, output, cacheRead, cacheCreation5m, cacheCreation1h int) {
baseInput = inputTokens
output = outputTokens
if usage != nil {
if usage.PromptTokensDetails != nil {
cacheRead = usage.PromptTokensDetails.CachedTokens
cacheCreation = usage.PromptTokensDetails.CachedCreationTokens
// 优先使用细分的 5m/1h token(Claude 新协议)
if usage.PromptTokensDetails.CachedCreation5mTokens > 0 || usage.PromptTokensDetails.CachedCreation1hTokens > 0 {
cacheCreation5m = usage.PromptTokensDetails.CachedCreation5mTokens
cacheCreation1h = usage.PromptTokensDetails.CachedCreation1hTokens
} else if usage.PromptTokensDetails.CachedCreationTokens > 0 {
// 旧协议无 TTL 细分:全部按 5m 兜底(保守计费)
cacheCreation5m = usage.PromptTokensDetails.CachedCreationTokens
}
}
if usage.CacheIncludedInPrompt {
baseInput = inputTokens - cacheRead - cacheCreation
baseInput = inputTokens - cacheRead - cacheCreation5m - cacheCreation1h
if baseInput < 0 {
baseInput = 0
}
+48 -25
View File
@@ -556,13 +556,15 @@ func TestComputeCost_ZeroTokens(t *testing.T) {
func TestComputeCost_WithCacheTokens(t *testing.T) {
pricing := &PricingResult{
InputPrice: 10.0,
OutputPrice: 30.0,
CacheReadPrice: 1.0, // $1/1M cache read
CacheCreationPrice: 2.5, // $2.5/1M cache creation
BillingMode: "token",
TenantMultiplier: 1.0,
Currency: "USD",
InputPrice: 10.0,
OutputPrice: 30.0,
CacheReadPrice: 1.0, // $1/1M cache read
CacheCreationPrice: 2.5, // $2.5/1M cache creation(5m 基础价)
CacheCreation5mPrice: 2.5, // 5m TTL 价格
CacheCreation1hPrice: 4.0, // 1h TTL 价格(2.5 × 1.6)
BillingMode: "token",
TenantMultiplier: 1.0,
Currency: "USD",
}
usage := &rcommon.Usage{
@@ -599,13 +601,15 @@ func TestComputeCost_WithCacheTokens(t *testing.T) {
func TestComputeCost_CacheNotIncludedInPrompt(t *testing.T) {
pricing := &PricingResult{
InputPrice: 10.0,
OutputPrice: 30.0,
CacheReadPrice: 1.0,
CacheCreationPrice: 2.5,
BillingMode: "token",
TenantMultiplier: 1.0,
Currency: "USD",
InputPrice: 10.0,
OutputPrice: 30.0,
CacheReadPrice: 1.0,
CacheCreationPrice: 2.5,
CacheCreation5mPrice: 2.5,
CacheCreation1hPrice: 4.0,
BillingMode: "token",
TenantMultiplier: 1.0,
Currency: "USD",
}
usage := &rcommon.Usage{
@@ -660,9 +664,9 @@ func TestComputeCost_CacheWithTenantMultiplier(t *testing.T) {
// ─── resolveTokenCounts ─────────────────────────────────────────────
func TestResolveTokenCounts_NoUsage(t *testing.T) {
baseIn, out, cr, cc := resolveTokenCounts(&PricingResult{}, 1000, 500, nil)
if baseIn != 1000 || out != 500 || cr != 0 || cc != 0 {
t.Errorf("expected (1000,500,0,0), got (%d,%d,%d,%d)", baseIn, out, cr, cc)
baseIn, out, cr, cc5m, cc1h := resolveTokenCounts(&PricingResult{}, 1000, 500, nil)
if baseIn != 1000 || out != 500 || cr != 0 || cc5m != 0 || cc1h != 0 {
t.Errorf("expected (1000,500,0,0,0), got (%d,%d,%d,%d,%d)", baseIn, out, cr, cc5m, cc1h)
}
}
@@ -675,12 +679,31 @@ func TestResolveTokenCounts_CacheIncluded(t *testing.T) {
},
CacheIncludedInPrompt: true,
}
baseIn, _, cr, cc := resolveTokenCounts(&PricingResult{}, 10_000, 500, usage)
baseIn, _, cr, cc5m, cc1h := resolveTokenCounts(&PricingResult{}, 10_000, 500, usage)
if baseIn != 5_000 {
t.Errorf("baseInput = %d, want 5000 (10000 - 3000 - 2000)", baseIn)
}
if cr != 3_000 || cc != 2_000 {
t.Errorf("cache: read=%d creation=%d, want 3000, 2000", cr, cc)
if cr != 3_000 || cc5m != 2_000 || cc1h != 0 {
t.Errorf("cache: read=%d creation5m=%d creation1h=%d, want 3000, 2000, 0", cr, cc5m, cc1h)
}
}
func TestResolveTokenCounts_CacheIncluded_5m1hSplit(t *testing.T) {
usage := &rcommon.Usage{
PromptTokens: 10_000,
PromptTokensDetails: &rcommon.TokenDetails{
CachedTokens: 3_000,
CachedCreation5mTokens: 1_200,
CachedCreation1hTokens: 800,
},
CacheIncludedInPrompt: true,
}
baseIn, _, cr, cc5m, cc1h := resolveTokenCounts(&PricingResult{}, 10_000, 500, usage)
if baseIn != 5_000 {
t.Errorf("baseInput = %d, want 5000 (10000 - 3000 - 1200 - 800)", baseIn)
}
if cr != 3_000 || cc5m != 1_200 || cc1h != 800 {
t.Errorf("cache: read=%d creation5m=%d creation1h=%d, want 3000, 1200, 800", cr, cc5m, cc1h)
}
}
@@ -693,7 +716,7 @@ func TestResolveTokenCounts_CacheExceedsInput(t *testing.T) {
},
CacheIncludedInPrompt: true,
}
baseIn, _, _, _ := resolveTokenCounts(&PricingResult{}, 100, 50, usage)
baseIn, _, _, _, _ := resolveTokenCounts(&PricingResult{}, 100, 50, usage)
// 100 - 200 - 50 = -150 → clamped to 0
if baseIn != 0 {
t.Errorf("baseInput = %d, want 0 (clamped)", baseIn)
@@ -708,7 +731,7 @@ func TestResolveTokenCounts_CacheNotIncluded(t *testing.T) {
},
CacheIncludedInPrompt: false,
}
baseIn, _, cr, _ := resolveTokenCounts(&PricingResult{}, 10_000, 500, usage)
baseIn, _, cr, _, _ := resolveTokenCounts(&PricingResult{}, 10_000, 500, usage)
// No deduction when cache not included
if baseIn != 10_000 {
t.Errorf("baseInput = %d, want 10000 (no deduction)", baseIn)
@@ -725,8 +748,8 @@ func TestResolveTokenCounts_NilTokenDetails(t *testing.T) {
PromptTokensDetails: nil,
CacheIncludedInPrompt: true,
}
baseIn, out, cr, cc := resolveTokenCounts(&PricingResult{}, 1000, 500, usage)
if baseIn != 1000 || out != 500 || cr != 0 || cc != 0 {
t.Errorf("expected (1000,500,0,0), got (%d,%d,%d,%d)", baseIn, out, cr, cc)
baseIn, out, cr, cc5m, cc1h := resolveTokenCounts(&PricingResult{}, 1000, 500, usage)
if baseIn != 1000 || out != 500 || cr != 0 || cc5m != 0 || cc1h != 0 {
t.Errorf("expected (1000,500,0,0,0), got (%d,%d,%d,%d,%d)", baseIn, out, cr, cc5m, cc1h)
}
}
+32 -1
View File
@@ -693,7 +693,7 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He
billing.IncrApiKeyQuotaUsed(settleCtx, rc.ApiKeyID, settleResult.ActualCost)
}
}
recordFailedUsage(provider, rc, selection, v.modelName, v.relayMode, v.isStream, err)
recordFailedUsageWithTokens(provider, rc, selection, v.modelName, v.relayMode, v.isStream, err, streamUsage)
finalizeTrace(trace, rc, hop, false, attempt, selection, err.Error(), info.LatencyMs())
return usage, v.billingResult, err
}
@@ -1002,6 +1002,37 @@ func recordFailedUsage(provider common.DataProvider, rc *RelayContext, selection
provider.RecordUsage(context.Background(), record)
}
// recordFailedUsageWithTokens 记录失败用量(含 token 明细,用于流中断等已有部分 usage 的场景)。
// 与 recordFailedUsage 的区别:此函数会填充 token 字段,避免报表中流中断记录的 token 全为 0。
func recordFailedUsageWithTokens(provider common.DataProvider, rc *RelayContext, selection *common.ChannelSelection, modelName string, relayMode constant.RelayMode, isStream bool, err error, usage *common.Usage) {
record := &common.UsageRecord{
TenantID: rc.TenantID,
UserID: rc.UserID,
ApiKeyID: rc.ApiKeyID,
ProjectID: rc.ProjectID,
ModelName: modelName,
RequestedModel: modelName,
RelayMode: int(relayMode),
IsStream: isStream,
Success: false,
RequestID: rc.RequestID,
Status: "error",
ErrorMessage: err.Error(),
}
if selection != nil {
record.ChannelID = selection.ChannelID
record.ChannelName = selection.ChannelName
record.ChannelType = selection.ChannelType
record.UpstreamModel = selection.UpstreamModelName
}
if usage != nil {
record.PromptTokens = usage.PromptTokens
record.CompletionTokens = usage.CompletionTokens
record.TotalTokens = usage.TotalTokens
}
provider.RecordUsage(context.Background(), record)
}
// recordChannelError 记录渠道错误事件到 chn_error_events(异步,不阻塞请求)
func recordChannelError(rc *RelayContext, selection *common.ChannelSelection, modelName string, attempt int, isFinal bool, err error, latencyMs float64) {
if commonlogic.DefaultChannelErrorWriter == nil {