diff --git a/internal/logic/billing/pricing.go b/internal/logic/billing/pricing.go index 8050dd4..bf89c6b 100644 --- a/internal/logic/billing/pricing.go +++ b/internal/logic/billing/pricing.go @@ -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 } diff --git a/internal/logic/billing/pricing_test.go b/internal/logic/billing/pricing_test.go index 82d1583..e9ebeaf 100644 --- a/internal/logic/billing/pricing_test.go +++ b/internal/logic/billing/pricing_test.go @@ -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) } } diff --git a/relay/handler/relay_handler.go b/relay/handler/relay_handler.go index 2a8ce19..4198d1d 100644 --- a/relay/handler/relay_handler.go +++ b/relay/handler/relay_handler.go @@ -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 {