diff --git a/backend/internal/service/gateway_usage_billing.go b/backend/internal/service/gateway_usage_billing.go index a8ed3dcf8..a5998f359 100644 --- a/backend/internal/service/gateway_usage_billing.go +++ b/backend/internal/service/gateway_usage_billing.go @@ -704,13 +704,13 @@ const responseModelBillingCostEpsilon = 1e-12 // 决定权交给上游,因此准入条件必须收紧: // - 只在渠道显式开启该模式时生效,其余模式一律不看响应模型; // - 一次请求内出现过互相冲突的模型声明时不采纳(无法确定上游究竟服务了哪个模型); -// - 图片 / 视频 / 网页搜索这类按次计费的请求不采纳:它们按张、按秒、按次定价, -// 与本模式的 token 定价准入检查不是同一套价格表,混用会让一个只验过 token 价的 -// 模型名去决定媒体单价。 +// - 图片 / 视频 / 网页搜索 / 语音 / 搜索附加费这类按次按量计费的请求不采纳:它们按张、 +// 按秒、按次定价,与本模式的 token 定价准入检查不是同一套价格表,混用会让一个只验过 +// token 价的模型名去决定媒体单价。新增按次计费形态时必须同步扩这个入参。 // // 调用方还必须额外满足两条:模型能被价格表确定性识别(见 -// hasIdentifiedResponseModelPricing / hasIdentifiedOpenAIResponsePricing),以及 -// 重算成本不高于基线成本——上游声明永远不能抬高用户费用。 +// hasIdentifiedResponseModelPricing / hasIdentifiedOpenAIResponsePricing),以及通过 +// responseModelBillingAdoptable 的成本准入。 func responseModelBillingDeclaration(source, responseModel string, conflict, mediaBilled bool) string { if source != BillingModelSourceResponse || conflict || mediaBilled { return "" @@ -718,6 +718,32 @@ func responseModelBillingDeclaration(source, responseModel string, conflict, med return strings.TrimSpace(responseModel) } +// responseModelBillingAdoptable 判定按响应模型重算出的成本能否取代基线成本。 +// 三条不变式,任一不满足都必须沿用基线(即开启本模式前的既有行为): +// +// 1. 不得更贵——上游声明永远不能抬高用户费用;epsilon 吸收两次计算之间的浮点末位误差。 +// 2. 不得把一笔本应计费的请求归零。价格表里存在把 token 价显式写成 0 的条目 +// (TokenPricingAbsent 只在 input/output 价**都缺失**时才为真,显式 0 算"有价"因而 +// 能通过确定性识别那道门),放任归零等于让上游自报一个免费模型名就能白嫖。 +// 基线本身就是 0 时不受影响,采纳与否都不改变金额。 +// 3. 不得把计费从管理员显式配置的渠道定价切到全局价格表。渠道定价查表只做精确键与 +// 前缀通配、**不剥日期后缀**,而全局价格表的确定性识别**会剥** 8 位日期后缀;上游 +// 普遍自报带日期的模型 ID(如 claude-opus-4-5-20251101),若允许跨源比较,渠道加价 +// 会被这类自报名字静默绕过。管理员若确实想让降级目标享受折扣,为它显式配一条渠道 +// 定价即可——那是一次可审计的显式授权。 +func responseModelBillingAdoptable(baseline, response *CostBreakdown, baselineChannelPriced, responseChannelPriced bool) bool { + if baseline == nil || response == nil { + return false + } + if response.TotalCost > baseline.TotalCost+responseModelBillingCostEpsilon { + return false + } + if response.TotalCost <= 0 && baseline.TotalCost > 0 { + return false + } + return !baselineChannelPriced || responseChannelPriced +} + // logResponseModelBillingApplied 记录一次实际生效的响应模型计费切换。 // 本模式下的少收由上游声明驱动,必须留下可审计痕迹;计费基准未变时不记录,避免刷屏。 func logResponseModelBillingApplied(component string, account *Account, requestID, baselineModel, responseModel string, baselineCost, responseCost *CostBreakdown) { @@ -814,21 +840,24 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage // 计算费用 cost := s.calculateRecordUsageCost(ctx, result, apiKey, billingModel, multiplier, imageMultiplier, opts) // response_model:按上游成功响应自报的模型计费(渠道显式开启才生效)。 - // 采纳条件见 responseModelBillingDeclaration + hasIdentifiedResponseModelPricing, - // 且重算成本不得高于基线——上游声明永远不能抬高用户费用。任一条件不满足都静默 - // 回落基线,即开启本模式前的既有行为。 + // 采纳条件见 responseModelBillingDeclaration + hasIdentifiedResponseModelPricing + // + responseModelBillingAdoptable。任一条件不满足都静默回落基线,即开启本模式前的 + // 既有行为。响应模型与基线同名时直接跳过:重算必然同价,白跑一次定价解析。 if responseModel := responseModelBillingDeclaration( input.BillingModelSource, result.UpstreamResponseModel, result.UpstreamResponseModelConflict, - result.ImageCount > 0, - ); responseModel != "" && s.hasIdentifiedResponseModelPricing(ctx, responseModel, apiKey) { - responseCost := s.calculateRecordUsageCost(ctx, result, apiKey, responseModel, multiplier, imageMultiplier, opts) - if cost != nil && responseCost != nil && responseCost.TotalCost <= cost.TotalCost+responseModelBillingCostEpsilon { - // billingModel 到此为止只是定价查表的入参,后续流程只消费 cost, - // 因此这里不改写它,改由日志记录实际生效的计费基准。 - logResponseModelBillingApplied("service.gateway", account, result.RequestID, billingModel, responseModel, cost, responseCost) - cost = responseCost + result.ImageCount > 0 || result.AudioUsage != nil || result.SearchCount > 0, + ); responseModel != "" && !strings.EqualFold(responseModel, strings.TrimSpace(billingModel)) { + if identified, responseChannelPriced := s.hasIdentifiedResponseModelPricing(ctx, responseModel, apiKey); identified { + responseCost := s.calculateRecordUsageCost(ctx, result, apiKey, responseModel, multiplier, imageMultiplier, opts) + baselineChannelPriced := s.resolveChannelPricing(ctx, billingModel, apiKey) != nil + if responseModelBillingAdoptable(cost, responseCost, baselineChannelPriced, responseChannelPriced) { + // billingModel 到此为止只是定价查表的入参,后续流程只消费 cost, + // 因此这里不改写它,改由日志记录实际生效的计费基准。 + logResponseModelBillingApplied("service.gateway", account, result.RequestID, billingModel, responseModel, cost, responseCost) + cost = responseCost + } } } @@ -995,18 +1024,20 @@ func (s *GatewayService) hasResolvableTokenPricing(ctx context.Context, model st return err == nil } -// hasIdentifiedResponseModelPricing 判断上游自报的响应模型是否可以作为计费基准。 +// hasIdentifiedResponseModelPricing 判断上游自报的响应模型是否可以作为计费基准, +// 并回传它是否解析到了渠道级定价(供 responseModelBillingAdoptable 的跨定价源守卫使用, +// 避免为此再解析一次)。 // 与 hasResolvableTokenPricing 的区别是刻意更严:只接受管理员为该模型显式配置的 // 渠道定价,或价格表中能被确定性识别的条目;不接受按子串猜出来的系列兜底价。 // 详见 responseModelBillingDeclaration 的说明。 -func (s *GatewayService) hasIdentifiedResponseModelPricing(ctx context.Context, model string, apiKey *APIKey) bool { +func (s *GatewayService) hasIdentifiedResponseModelPricing(ctx context.Context, model string, apiKey *APIKey) (identified bool, channelPriced bool) { if strings.TrimSpace(model) == "" { - return false + return false, false } if s.resolveChannelPricing(ctx, model, apiKey) != nil { - return true + return true, true } - return s.billingService.HasIdentifiedTokenPricing(model) + return s.billingService.HasIdentifiedTokenPricing(model), false } // resolveChannelPricing 检查指定模型是否存在渠道级别定价。 diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go index 78318a79e..c2de3c647 100644 --- a/backend/internal/service/openai_gateway_usage.go +++ b/backend/internal/service/openai_gateway_usage.go @@ -241,26 +241,33 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec cost = &CostBreakdown{BillingMode: string(BillingModeToken)} } // response_model:按上游成功响应自报的模型计费(渠道显式开启才生效)。 - // 采纳条件见 responseModelBillingDeclaration + hasIdentifiedOpenAIResponsePricing, - // 且重算成本不得高于基线——上游声明永远不能抬高用户费用。任一条件不满足都静默 - // 回落基线,即开启本模式前的既有行为。 + // 采纳条件见 responseModelBillingDeclaration + hasIdentifiedOpenAIResponsePricing + // + responseModelBillingAdoptable。任一条件不满足都静默回落基线,即开启本模式前的 + // 既有行为。响应模型与基线同名时直接跳过:重算必然同价,白跑一次定价解析。 + baselineBillingModel := firstUsageBillingModel(billingModels) if responseModel := responseModelBillingDeclaration( input.BillingModelSource, result.UpstreamResponseModel, result.UpstreamResponseModelConflict, - result.ImageCount > 0 || result.VideoCount > 0 || result.WebSearchCalls > 0, - ); responseModel != "" && s.hasIdentifiedOpenAIResponsePricing(ctx, responseModel, apiKey) { - responseModels := usageBillingModelCandidates(responseModel) - responseCost, responseErr := s.calculateOpenAIRecordUsageCost( - ctx, result, apiKey, responseModels, multiplier, imageMultiplier, - videoMultiplier, baseMultiplier, tokens, serviceTier, longContextBillingEnabled, - ) - if responseErr == nil && responseCost != nil && cost != nil && - responseCost.TotalCost <= cost.TotalCost+responseModelBillingCostEpsilon { - logResponseModelBillingApplied("service.openai_gateway", account, result.RequestID, - firstUsageBillingModel(billingModels), responseModel, cost, responseCost) - billingModels = responseModels - cost = responseCost + result.ImageCount > 0 || result.VideoCount > 0 || result.WebSearchCalls > 0 || + result.AudioUsage != nil || result.SearchCount > 0, + ); responseModel != "" && !strings.EqualFold(responseModel, baselineBillingModel) { + if identified, responseChannelPriced := s.hasIdentifiedOpenAIResponsePricing(ctx, responseModel, apiKey); identified { + responseModels := usageBillingModelCandidates(responseModel) + responseCost, responseErr := s.calculateOpenAIRecordUsageCost( + ctx, result, apiKey, responseModels, multiplier, imageMultiplier, + videoMultiplier, baseMultiplier, tokens, serviceTier, longContextBillingEnabled, + ) + // 基线定价源以 baselineBillingModel 为准:它正是 calculateOpenAIRecordUsageCost + // 内部做渠道定价判断时使用的模型,且"首候选有渠道价"必然意味着首候选就是实际 + // 定价基准(有渠道价就一定能算出价,循环不会落到后续候选)。 + baselineChannelPriced := s.resolveOpenAIChannelPricing(ctx, baselineBillingModel, apiKey) != nil + if responseErr == nil && responseModelBillingAdoptable(cost, responseCost, baselineChannelPriced, responseChannelPriced) { + logResponseModelBillingApplied("service.openai_gateway", account, result.RequestID, + baselineBillingModel, responseModel, cost, responseCost) + billingModels = responseModels + cost = responseCost + } } } @@ -456,19 +463,21 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec return nil } -// hasIdentifiedOpenAIResponsePricing 判断上游自报的响应模型是否可以作为计费基准。 +// hasIdentifiedOpenAIResponsePricing 判断上游自报的响应模型是否可以作为计费基准, +// 并回传它是否解析到了渠道级定价(供 responseModelBillingAdoptable 的跨定价源守卫使用, +// 避免为此再解析一次)。 // 只接受管理员为该模型显式配置的渠道定价,或价格表中能被确定性识别的条目; // 刻意不接受按子串猜出来的系列兜底价,否则上游随便编一个含 "haiku" 的名字就能把 // 计费拉到最便宜的系列价上。详见 responseModelBillingDeclaration。 -func (s *OpenAIGatewayService) hasIdentifiedOpenAIResponsePricing(ctx context.Context, model string, apiKey *APIKey) bool { +func (s *OpenAIGatewayService) hasIdentifiedOpenAIResponsePricing(ctx context.Context, model string, apiKey *APIKey) (identified bool, channelPriced bool) { model = strings.TrimSpace(model) if model == "" { - return false + return false, false } if s.resolveOpenAIChannelPricing(ctx, model, apiKey) != nil { - return true + return true, true } - return s.billingService.HasIdentifiedTokenPricing(model) + return s.billingService.HasIdentifiedTokenPricing(model), false } func (s *OpenAIGatewayService) calculateOpenAIRecordUsageCost( diff --git a/backend/internal/service/response_model_billing_test.go b/backend/internal/service/response_model_billing_test.go index 451a48b05..9194a1684 100644 --- a/backend/internal/service/response_model_billing_test.go +++ b/backend/internal/service/response_model_billing_test.go @@ -452,6 +452,135 @@ func TestOpenAIGatewayServiceRecordUsage_ResponseModelRejectsUnidentifiedFamilyN require.InDelta(t, baselineCost.ActualCost, userRepo.lastAmount, 1e-12) } +// --- 成本准入的三条不变式 --- + +func TestResponseModelBillingAdoptable(t *testing.T) { + t.Parallel() + cost := func(total float64) *CostBreakdown { + return &CostBreakdown{TotalCost: total, ActualCost: total} + } + tests := []struct { + name string + baseline *CostBreakdown + response *CostBreakdown + baselineChannelPriced bool + responseChannelPriced bool + want bool + }{ + // 1. 不得更贵 + {name: "cheaper_adopted", baseline: cost(1), response: cost(0.5), want: true}, + {name: "equal_adopted", baseline: cost(1), response: cost(1), want: true}, + {name: "float_noise_within_epsilon_adopted", baseline: cost(1), response: cost(1 + 1e-13), want: true}, + {name: "pricier_rejected", baseline: cost(1), response: cost(1.0001)}, + + // 2. 不得把一笔本应计费的请求归零(价格表里有显式写 0 的条目,能通过确定性识别) + {name: "zeroing_a_billable_request_rejected", baseline: cost(1), response: cost(0)}, + {name: "negative_cost_rejected_as_zeroing", baseline: cost(1), response: cost(-1)}, + {name: "already_zero_baseline_unaffected", baseline: cost(0), response: cost(0), want: true}, + + // 3. 不得从渠道定价跨到全局价格表(否则渠道加价被带日期的自报模型名绕过) + {name: "channel_priced_baseline_to_global_rejected", baseline: cost(1), response: cost(0.5), baselineChannelPriced: true}, + {name: "channel_priced_on_both_sides_adopted", baseline: cost(1), response: cost(0.5), baselineChannelPriced: true, responseChannelPriced: true, want: true}, + {name: "global_baseline_to_channel_priced_adopted", baseline: cost(1), response: cost(0.5), responseChannelPriced: true, want: true}, + + {name: "nil_baseline_rejected", response: cost(0.5)}, + {name: "nil_response_rejected", baseline: cost(1)}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tt.want, responseModelBillingAdoptable( + tt.baseline, tt.response, tt.baselineChannelPriced, tt.responseChannelPriced, + )) + }) + } +} + +// --- 按次/按量计费请求一律不采纳(门的调用点接线) --- +// +// 搜索附加费是叠加在 token 成本之上的,所以"采纳与否"会体现在最终金额上,本用例因此 +// 能真正区分两条分支。语音(AudioUsage)走的是与模型无关的按量单价,采纳与否金额相同, +// 无法用金额断言区分,故只由 TestResponseModelBillingDeclaration 覆盖门本身。 + +func TestGatewayServiceRecordUsage_ResponseModelSkippedForSearchSurchargedRequest(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + svc := newGatewayRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{}) + tokens := UsageTokens{InputTokens: 100, OutputTokens: 50} + cheaper, pricier, _, pricierCost := orderedResponseBillingModels(t, svc.billingService, tokens, anthropicCheapFixtureModel, anthropicPriceyFixtureModel) + + const searchCalls = 2 + searchCost := svc.billingService.CalculateSearchCost(searchCalls, nil, 1.1) + require.NotNil(t, searchCost) + require.Greater(t, searchCost.ActualCost, 0.0, "夹具附加费必须非零,否则断言分不出两条分支") + + err := svc.RecordUsage(context.Background(), &RecordUsageInput{ + Result: &ForwardResult{ + RequestID: "gateway_response_model_search_surcharge", + Usage: ClaudeUsage{InputTokens: 100, OutputTokens: 50}, + Model: pricier, + UpstreamResponseModel: cheaper, + SearchCount: searchCalls, + Duration: time.Second, + }, + APIKey: &APIKey{ID: 501, Quota: 100}, + User: &User{ID: 601}, + Account: &Account{ID: 701}, + ChannelUsageFields: ChannelUsageFields{ + ChannelID: 9, + OriginalModel: pricier, + ChannelMappedModel: pricier, + BillingModelSource: BillingModelSourceResponse, + }, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + want := pricierCost.ActualCost + searchCost.ActualCost + require.InDelta(t, want, usageRepo.lastLog.ActualCost, 1e-12) + require.InDelta(t, want, userRepo.lastAmount, 1e-12) +} + +func TestOpenAIGatewayServiceRecordUsage_ResponseModelSkippedForSearchSurchargedRequest(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{}, nil) + tokens := UsageTokens{InputTokens: 20, OutputTokens: 10} + cheaper, pricier, _, pricierCost := orderedResponseBillingModels(t, svc.billingService, tokens, openAICheapFixtureModel, openAIPriceyFixtureModel) + + const searchCalls = 3 + searchCost := svc.billingService.CalculateSearchCost(searchCalls, nil, 1.1) + require.NotNil(t, searchCost) + require.Greater(t, searchCost.ActualCost, 0.0, "夹具附加费必须非零,否则断言分不出两条分支") + + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "openai_response_model_search_surcharge", + Model: pricier, + UpstreamModel: pricier, + UpstreamResponseModel: cheaper, + SearchCount: searchCalls, + Usage: OpenAIUsage{InputTokens: 20, OutputTokens: 10}, + Duration: time.Second, + }, + APIKey: &APIKey{ID: 10}, + User: &User{ID: 20}, + Account: &Account{ID: 30}, + ChannelUsageFields: ChannelUsageFields{ + ChannelID: 9, + OriginalModel: pricier, + ChannelMappedModel: pricier, + BillingModelSource: BillingModelSourceResponse, + }, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + want := pricierCost.ActualCost + searchCost.ActualCost + require.InDelta(t, want, usageRepo.lastLog.ActualCost, 1e-12) + require.InDelta(t, want, userRepo.lastAmount, 1e-12) +} + // --- 渠道配置透传 --- func TestToUsageFields_ResponseModelSourcePassesThrough(t *testing.T) {