mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 16:08:02 +08:00
fix(billing): harden response-model billing admission
Three guards on the response_model billing basis, all scoped to the opt-in channel mode so existing channels are unaffected. 1. Per-unit billing gate was stale. Audio (AudioUsage) and the search surcharge (SearchCount) reached the billing paths after this branch was cut; both are priced per unit rather than per token, so they must be excluded like image/video/web-search already are. Audio pricing ignores the model entirely, so the previous code "adopted" a basis switch that changed nothing and emitted a misleading audit log for it. 2. Never zero out a billable request. A catalog entry whose token prices are explicitly 0 still passes the identified-pricing gate (TokenPricingAbsent only means both prices are missing), so an upstream could declare a free model name and drop the bill to zero. Reject a zero (or negative) recomputation whenever the baseline was billable; an already-zero baseline is unaffected. 3. Never cross from channel pricing to the global table. Channel pricing matches exact keys and prefix wildcards and does not strip date suffixes, while the global table's identified lookup does. Upstreams routinely declare dated model IDs (claude-opus-4-5-20251101), so allowing a cross-source comparison would silently bypass an administrator's channel markup on essentially every request. Admins who want a downgrade target discounted can price it explicitly on the channel. Also skip the recomputation entirely when the declared model equals the baseline: it is provably the same cost and only burned a pricing resolve. The identified-pricing helpers now return whether the model resolved to channel pricing so the third guard costs no extra resolve.
This commit is contained in:
@@ -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 检查指定模型是否存在渠道级别定价。
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user