mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-19 16:17:56 +00:00
fix(billing): harden response-model billing and repair its test fixtures
按上游响应模型计费的准入过宽、且自带用例必然失败,本次一并修复。 严格化准入 - 新增 PricingService.GetIdentifiedModelPricing / BillingService.HasIdentifiedTokenPricing: 只接受价格表中能被确定性识别的条目(精确名、已知拼写变体、去掉日期版本后缀), 不再接受 getFallbackPricing / matchByModelFamily 按子串猜出的系列兜底价。 此前上游只要自报一个含 "haiku" 的编造名字就能被判定"已定价",把账单压到最便宜的 系列价(实测 claude-opus-4.8 基线 $0.0019250 → $0.0000963,20 倍少收)。 GetModelPricing 的对外行为不变,仅把前三步查找抽成共用函数。 - 图片 / 视频 / 网页搜索请求不再走响应模型覆盖:这些路径按张、按秒、按次定价, 与准入检查所验的 token 价不是同一套价格表。 - 准入判断抽成 responseModelBillingDeclaration,两条计费主干共用同一套规则。 正确性与可观测性 - 去掉 recordUsageCore 中 billingModel 的无效赋值(ineffassign 已启用,会让 golangci-lint 直接失败),改由日志表达实际生效的计费基准。 - 补 cost != nil 守卫,与 OpenAI 侧及本文件既有写法对齐。 - 每次实际生效的基准切换记一条 billing.response_model_applied,少收可审计。 - 修正 upstreamResponseModelObserver 上"冲突仅用于诊断、永不影响计费"的过期注释。 测试 - gpt-5.1 与 gpt-5.5 实际共用同一条 gpt-5.4 价格,夹具"价格必须不同"的前置断言 必然失败,OpenAI 侧 3 个用例(含 4 个子用例)从未跑通;改用 gpt-5.4-nano / gpt-5.5。Anthropic 侧 claude-opus-4 不是价格表精确条目,改用 claude-opus-4.8。 - 夹具增加"必须可被确定性识别"的前置断言,避免用例被更靠前的门挡掉而失去判别力。 - 新增:准入规则表驱动用例、可识别性判定用例、编造家族名在两条主干上均被拒的用例。 前端 - 选择该模式时提示"计费基准以上游自报模型为准,只降不升,仅对可信上游启用"。
This commit is contained in:
@@ -819,6 +819,30 @@ func (s *BillingService) getFallbackPricing(model string) *ModelPricing {
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasIdentifiedTokenPricing 判断模型能否在价格表中被"确定性识别"出 token 价格。
|
||||
//
|
||||
// 与 GetModelPricing 的关键区别:本函数拒绝按子串猜系列的兜底。GetModelPricing 会
|
||||
// 让任意含 "haiku"/"opus"/"claude" 的名字(哪怕是不存在的型号)落到 getFallbackPricing
|
||||
// 的系列兜底价上,因此凡是模型名来自外部、且"能查到价"会直接影响计费金额的场景
|
||||
// (如按上游响应自报模型计费),都必须用本函数而不是 GetModelPricing 做准入判断。
|
||||
func (s *BillingService) HasIdentifiedTokenPricing(model string) bool {
|
||||
if s == nil {
|
||||
return false
|
||||
}
|
||||
model = strings.ToLower(strings.TrimSpace(model))
|
||||
if model == "" {
|
||||
return false
|
||||
}
|
||||
if s.pricingService != nil {
|
||||
// 仅有图片价的条目不能用于 token 计费,口径与 GetModelPricing 保持一致。
|
||||
if pricing := s.pricingService.GetIdentifiedModelPricing(model); pricing != nil && !pricing.TokenPricingAbsent {
|
||||
return true
|
||||
}
|
||||
}
|
||||
pricing, ok := s.fallbackPrices[model]
|
||||
return ok && pricing != nil
|
||||
}
|
||||
|
||||
// GetModelPricing 获取模型价格配置
|
||||
func (s *BillingService) GetModelPricing(model string) (*ModelPricing, error) {
|
||||
// 标准化模型名称(转小写)
|
||||
|
||||
@@ -693,6 +693,54 @@ type recordUsageCoreInput struct {
|
||||
ChannelUsageFields
|
||||
}
|
||||
|
||||
// responseModelBillingCostEpsilon 吸收两次成本计算之间的浮点末位误差,
|
||||
// 避免同价模型因浮点误差被判成"更贵"而白白放弃采纳。
|
||||
const responseModelBillingCostEpsilon = 1e-12
|
||||
|
||||
// responseModelBillingDeclaration 返回可用于计费的上游响应模型;返回空字符串表示
|
||||
// 必须沿用基线计费模型。两条计费主干(Anthropic 系 / OpenAI 系)共用本准入判断。
|
||||
//
|
||||
// 渠道把 billing_model_source 设为 response_model,等于把"按哪个模型计价"的一部分
|
||||
// 决定权交给上游,因此准入条件必须收紧:
|
||||
// - 只在渠道显式开启该模式时生效,其余模式一律不看响应模型;
|
||||
// - 一次请求内出现过互相冲突的模型声明时不采纳(无法确定上游究竟服务了哪个模型);
|
||||
// - 图片 / 视频 / 网页搜索这类按次计费的请求不采纳:它们按张、按秒、按次定价,
|
||||
// 与本模式的 token 定价准入检查不是同一套价格表,混用会让一个只验过 token 价的
|
||||
// 模型名去决定媒体单价。
|
||||
//
|
||||
// 调用方还必须额外满足两条:模型能被价格表确定性识别(见
|
||||
// hasIdentifiedResponseModelPricing / hasIdentifiedOpenAIResponsePricing),以及
|
||||
// 重算成本不高于基线成本——上游声明永远不能抬高用户费用。
|
||||
func responseModelBillingDeclaration(source, responseModel string, conflict, mediaBilled bool) string {
|
||||
if source != BillingModelSourceResponse || conflict || mediaBilled {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(responseModel)
|
||||
}
|
||||
|
||||
// logResponseModelBillingApplied 记录一次实际生效的响应模型计费切换。
|
||||
// 本模式下的少收由上游声明驱动,必须留下可审计痕迹;计费基准未变时不记录,避免刷屏。
|
||||
func logResponseModelBillingApplied(component string, account *Account, requestID, baselineModel, responseModel string, baselineCost, responseCost *CostBreakdown) {
|
||||
baselineModel = strings.TrimSpace(baselineModel)
|
||||
responseModel = strings.TrimSpace(responseModel)
|
||||
if strings.EqualFold(baselineModel, responseModel) {
|
||||
return
|
||||
}
|
||||
attrs := []any{
|
||||
"component", component,
|
||||
"request_id", strings.TrimSpace(requestID),
|
||||
"baseline_model", baselineModel,
|
||||
"response_model", responseModel,
|
||||
}
|
||||
if baselineCost != nil && responseCost != nil {
|
||||
attrs = append(attrs, "baseline_cost", baselineCost.TotalCost, "billed_cost", responseCost.TotalCost)
|
||||
}
|
||||
if account != nil {
|
||||
attrs = append(attrs, "platform", account.Platform, "account_id", account.ID)
|
||||
}
|
||||
slog.Info("billing.response_model_applied", attrs...)
|
||||
}
|
||||
|
||||
// recordUsageCore 是 RecordUsage 和 RecordUsageWithLongContext 的统一实现。
|
||||
// LongContextThreshold > 0 时 Token 计费回退走 CalculateCostWithLongContext。
|
||||
func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsageCoreInput, opts *recordUsageOpts) error {
|
||||
@@ -765,18 +813,22 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage
|
||||
|
||||
// 计算费用
|
||||
cost := s.calculateRecordUsageCost(ctx, result, apiKey, billingModel, multiplier, imageMultiplier, opts)
|
||||
// response_model is an explicit, opt-in billing mode. The response model is
|
||||
// only accepted when it is unambiguous, priced, and cannot increase the
|
||||
// existing charge (an upstream declaration must never be able to raise cost).
|
||||
if input.BillingModelSource == BillingModelSourceResponse {
|
||||
responseModel := strings.TrimSpace(result.UpstreamResponseModel)
|
||||
if responseModel != "" && !result.UpstreamResponseModelConflict &&
|
||||
s.hasResolvableTokenPricing(ctx, responseModel, apiKey) {
|
||||
responseCost := s.calculateRecordUsageCost(ctx, result, apiKey, responseModel, multiplier, imageMultiplier, opts)
|
||||
if responseCost != nil && responseCost.TotalCost <= cost.TotalCost+1e-12 {
|
||||
billingModel = responseModel
|
||||
cost = responseCost
|
||||
}
|
||||
// response_model:按上游成功响应自报的模型计费(渠道显式开启才生效)。
|
||||
// 采纳条件见 responseModelBillingDeclaration + hasIdentifiedResponseModelPricing,
|
||||
// 且重算成本不得高于基线——上游声明永远不能抬高用户费用。任一条件不满足都静默
|
||||
// 回落基线,即开启本模式前的既有行为。
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -943,6 +995,20 @@ func (s *GatewayService) hasResolvableTokenPricing(ctx context.Context, model st
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// hasIdentifiedResponseModelPricing 判断上游自报的响应模型是否可以作为计费基准。
|
||||
// 与 hasResolvableTokenPricing 的区别是刻意更严:只接受管理员为该模型显式配置的
|
||||
// 渠道定价,或价格表中能被确定性识别的条目;不接受按子串猜出来的系列兜底价。
|
||||
// 详见 responseModelBillingDeclaration 的说明。
|
||||
func (s *GatewayService) hasIdentifiedResponseModelPricing(ctx context.Context, model string, apiKey *APIKey) bool {
|
||||
if strings.TrimSpace(model) == "" {
|
||||
return false
|
||||
}
|
||||
if s.resolveChannelPricing(ctx, model, apiKey) != nil {
|
||||
return true
|
||||
}
|
||||
return s.billingService.HasIdentifiedTokenPricing(model)
|
||||
}
|
||||
|
||||
// resolveChannelPricing 检查指定模型是否存在渠道级别定价。
|
||||
// 返回非 nil 的 ResolvedPricing 表示有渠道定价,nil 表示走默认定价路径。
|
||||
func (s *GatewayService) resolveChannelPricing(ctx context.Context, billingModel string, apiKey *APIKey) *ResolvedPricing {
|
||||
|
||||
@@ -240,21 +240,27 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
|
||||
).Warn("openai_usage.pricing_missing_record_zero_cost", zap.Error(err))
|
||||
cost = &CostBreakdown{BillingMode: string(BillingModeToken)}
|
||||
}
|
||||
// response_model is opt-in and conservative: use an unambiguous priced
|
||||
// response declaration only when it does not increase the baseline charge.
|
||||
if input.BillingModelSource == BillingModelSourceResponse {
|
||||
responseModel := strings.TrimSpace(result.UpstreamResponseModel)
|
||||
if responseModel != "" && !result.UpstreamResponseModelConflict && s.hasResolvableOpenAIResponsePricing(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+1e-12 {
|
||||
billingModels = responseModels
|
||||
cost = responseCost
|
||||
}
|
||||
// response_model:按上游成功响应自报的模型计费(渠道显式开启才生效)。
|
||||
// 采纳条件见 responseModelBillingDeclaration + hasIdentifiedOpenAIResponsePricing,
|
||||
// 且重算成本不得高于基线——上游声明永远不能抬高用户费用。任一条件不满足都静默
|
||||
// 回落基线,即开启本模式前的既有行为。
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -450,7 +456,11 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) hasResolvableOpenAIResponsePricing(ctx context.Context, model string, apiKey *APIKey) bool {
|
||||
// hasIdentifiedOpenAIResponsePricing 判断上游自报的响应模型是否可以作为计费基准。
|
||||
// 只接受管理员为该模型显式配置的渠道定价,或价格表中能被确定性识别的条目;
|
||||
// 刻意不接受按子串猜出来的系列兜底价,否则上游随便编一个含 "haiku" 的名字就能把
|
||||
// 计费拉到最便宜的系列价上。详见 responseModelBillingDeclaration。
|
||||
func (s *OpenAIGatewayService) hasIdentifiedOpenAIResponsePricing(ctx context.Context, model string, apiKey *APIKey) bool {
|
||||
model = strings.TrimSpace(model)
|
||||
if model == "" {
|
||||
return false
|
||||
@@ -458,11 +468,7 @@ func (s *OpenAIGatewayService) hasResolvableOpenAIResponsePricing(ctx context.Co
|
||||
if s.resolveOpenAIChannelPricing(ctx, model, apiKey) != nil {
|
||||
return true
|
||||
}
|
||||
if s.billingService == nil {
|
||||
return false
|
||||
}
|
||||
_, err := s.billingService.GetModelPricing(model)
|
||||
return err == nil
|
||||
return s.billingService.HasIdentifiedTokenPricing(model)
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) calculateOpenAIRecordUsageCost(
|
||||
|
||||
@@ -652,6 +652,33 @@ func (s *PricingService) GetModelPricing(modelName string) *LiteLLMModelPricing
|
||||
modelLower := strings.ToLower(strings.TrimSpace(modelName))
|
||||
lookupCandidates := s.buildModelLookupCandidates(modelLower)
|
||||
|
||||
// 1~3. 确定性识别(精确名 / 已知拼写变体 / 去掉日期版本后缀)
|
||||
if pricing := s.lookupIdentifiedModelPricingLocked(lookupCandidates); pricing != nil {
|
||||
return pricing
|
||||
}
|
||||
|
||||
// 4. 基于模型系列匹配(Claude)
|
||||
if pricing := s.matchByModelFamily(lookupCandidates[0]); pricing != nil {
|
||||
return pricing
|
||||
}
|
||||
|
||||
// 5. OpenAI 模型回退策略
|
||||
if strings.HasPrefix(lookupCandidates[0], "gpt-") {
|
||||
return s.matchOpenAIModel(lookupCandidates[0])
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// lookupIdentifiedModelPricingLocked 只做"确定性识别"的三步查找:精确键、已知拼写
|
||||
// 变体、去掉日期/版本后缀后的同名条目。它刻意不包含 matchByModelFamily /
|
||||
// matchOpenAIModel 这类按子串猜系列的兜底——那些兜底会给任意名字都返回一个价格。
|
||||
// 调用方必须持有 s.mu 读锁。
|
||||
func (s *PricingService) lookupIdentifiedModelPricingLocked(lookupCandidates []string) *LiteLLMModelPricing {
|
||||
if len(lookupCandidates) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 1. 精确匹配
|
||||
for _, candidate := range lookupCandidates {
|
||||
if candidate == "" {
|
||||
@@ -681,19 +708,26 @@ func (s *PricingService) GetModelPricing(modelName string) *LiteLLMModelPricing
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 基于模型系列匹配(Claude)
|
||||
if pricing := s.matchByModelFamily(lookupCandidates[0]); pricing != nil {
|
||||
return pricing
|
||||
}
|
||||
|
||||
// 5. OpenAI 模型回退策略
|
||||
if strings.HasPrefix(lookupCandidates[0], "gpt-") {
|
||||
return s.matchOpenAIModel(lookupCandidates[0])
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetIdentifiedModelPricing 在价格表中确定性地识别模型,识别不到时返回 nil。
|
||||
// 与 GetModelPricing 的区别:不会退化成按 "opus"/"haiku" 之类子串猜出的系列兜底价。
|
||||
// 用于必须区分"这是价格表里已知的模型"和"这只是名字里带某个关键词"的场景。
|
||||
func (s *PricingService) GetIdentifiedModelPricing(modelName string) *LiteLLMModelPricing {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
modelLower := strings.ToLower(strings.TrimSpace(modelName))
|
||||
if modelLower == "" {
|
||||
return nil
|
||||
}
|
||||
return s.lookupIdentifiedModelPricingLocked(s.buildModelLookupCandidates(modelLower))
|
||||
}
|
||||
|
||||
func (s *PricingService) buildModelLookupCandidates(modelLower string) []string {
|
||||
rawCandidates := []string{
|
||||
modelLower,
|
||||
|
||||
@@ -10,6 +10,20 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// 夹具模型必须同时满足两个条件,否则测的就不是想测的那条规则:
|
||||
// 1. 两者价格不同——否则"更便宜才采纳"的断言退化成恒真;
|
||||
// 2. 两者都能被 HasIdentifiedTokenPricing 确定性识别(即价格表里的精确条目),
|
||||
// 否则请求会先被"响应模型必须可识别"这道更靠前的门挡掉,成本比较根本走不到。
|
||||
//
|
||||
// claude-opus-4 / gpt-5.1 之类的名字不满足条件 2(前者不是 fallback 精确键,
|
||||
// 后者与 gpt-5.5 共用同一条 gpt-5.4 价格因而也不满足条件 1)。
|
||||
const (
|
||||
anthropicCheapFixtureModel = "claude-sonnet-4"
|
||||
anthropicPriceyFixtureModel = "claude-opus-4.8"
|
||||
openAICheapFixtureModel = "gpt-5.4-nano"
|
||||
openAIPriceyFixtureModel = "gpt-5.5"
|
||||
)
|
||||
|
||||
// orderedResponseBillingModels 返回 (cheaper, pricier) 及各自成本,按当前价格表排序,
|
||||
// 使断言不依赖两个具体模型的价格大小关系(价格表调整时测试仍然自洽)。
|
||||
func orderedResponseBillingModels(t *testing.T, svc *BillingService, tokens UsageTokens, a, b string) (string, string, *CostBreakdown, *CostBreakdown) {
|
||||
@@ -19,6 +33,8 @@ func orderedResponseBillingModels(t *testing.T, svc *BillingService, tokens Usag
|
||||
costB, err := svc.CalculateCost(b, tokens, 1.1)
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, costA.TotalCost, costB.TotalCost, "fixture prices for %s and %s must differ", a, b)
|
||||
require.True(t, svc.HasIdentifiedTokenPricing(a), "fixture model %s must be identifiable in the pricing table", a)
|
||||
require.True(t, svc.HasIdentifiedTokenPricing(b), "fixture model %s must be identifiable in the pricing table", b)
|
||||
if costA.TotalCost < costB.TotalCost {
|
||||
return a, b, costA, costB
|
||||
}
|
||||
@@ -32,7 +48,7 @@ func TestGatewayServiceRecordUsage_ResponseModelBillsCheaperResponseModel(t *tes
|
||||
userRepo := &openAIRecordUsageUserRepoStub{}
|
||||
svc := newGatewayRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{})
|
||||
tokens := UsageTokens{InputTokens: 100, OutputTokens: 50}
|
||||
cheaper, pricier, cheaperCost, _ := orderedResponseBillingModels(t, svc.billingService, tokens, "claude-sonnet-4", "claude-opus-4")
|
||||
cheaper, pricier, cheaperCost, _ := orderedResponseBillingModels(t, svc.billingService, tokens, anthropicCheapFixtureModel, anthropicPriceyFixtureModel)
|
||||
|
||||
err := svc.RecordUsage(context.Background(), &RecordUsageInput{
|
||||
Result: &ForwardResult{
|
||||
@@ -72,7 +88,7 @@ func TestGatewayServiceRecordUsage_ResponseModelRejectsPricierResponseModel(t *t
|
||||
userRepo := &openAIRecordUsageUserRepoStub{}
|
||||
svc := newGatewayRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{})
|
||||
tokens := UsageTokens{InputTokens: 100, OutputTokens: 50}
|
||||
cheaper, pricier, cheaperCost, _ := orderedResponseBillingModels(t, svc.billingService, tokens, "claude-sonnet-4", "claude-opus-4")
|
||||
cheaper, pricier, cheaperCost, _ := orderedResponseBillingModels(t, svc.billingService, tokens, anthropicCheapFixtureModel, anthropicPriceyFixtureModel)
|
||||
|
||||
err := svc.RecordUsage(context.Background(), &RecordUsageInput{
|
||||
Result: &ForwardResult{
|
||||
@@ -135,7 +151,7 @@ func TestGatewayServiceRecordUsage_ResponseModelSafeFallbacks(t *testing.T) {
|
||||
userRepo := &openAIRecordUsageUserRepoStub{}
|
||||
svc := newGatewayRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{})
|
||||
tokens := UsageTokens{InputTokens: 100, OutputTokens: 50}
|
||||
cheaper, pricier, _, pricierCost := orderedResponseBillingModels(t, svc.billingService, tokens, "claude-sonnet-4", "claude-opus-4")
|
||||
cheaper, pricier, _, pricierCost := orderedResponseBillingModels(t, svc.billingService, tokens, anthropicCheapFixtureModel, anthropicPriceyFixtureModel)
|
||||
|
||||
err := svc.RecordUsage(context.Background(), &RecordUsageInput{
|
||||
Result: &ForwardResult{
|
||||
@@ -172,7 +188,7 @@ func TestOpenAIGatewayServiceRecordUsage_ResponseModelBillsCheaperResponseModel(
|
||||
userRepo := &openAIRecordUsageUserRepoStub{}
|
||||
svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{}, nil)
|
||||
tokens := UsageTokens{InputTokens: 20, OutputTokens: 10}
|
||||
cheaper, pricier, cheaperCost, _ := orderedResponseBillingModels(t, svc.billingService, tokens, "gpt-5.1", "gpt-5.5")
|
||||
cheaper, pricier, cheaperCost, _ := orderedResponseBillingModels(t, svc.billingService, tokens, openAICheapFixtureModel, openAIPriceyFixtureModel)
|
||||
|
||||
err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
|
||||
Result: &OpenAIForwardResult{
|
||||
@@ -212,7 +228,7 @@ func TestOpenAIGatewayServiceRecordUsage_ResponseModelRejectsPricierResponseMode
|
||||
userRepo := &openAIRecordUsageUserRepoStub{}
|
||||
svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{}, nil)
|
||||
tokens := UsageTokens{InputTokens: 20, OutputTokens: 10}
|
||||
cheaper, pricier, cheaperCost, _ := orderedResponseBillingModels(t, svc.billingService, tokens, "gpt-5.1", "gpt-5.5")
|
||||
cheaper, pricier, cheaperCost, _ := orderedResponseBillingModels(t, svc.billingService, tokens, openAICheapFixtureModel, openAIPriceyFixtureModel)
|
||||
|
||||
err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
|
||||
Result: &OpenAIForwardResult{
|
||||
@@ -276,7 +292,7 @@ func TestOpenAIGatewayServiceRecordUsage_ResponseModelSafeFallbacks(t *testing.T
|
||||
userRepo := &openAIRecordUsageUserRepoStub{}
|
||||
svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{}, nil)
|
||||
tokens := UsageTokens{InputTokens: 20, OutputTokens: 10}
|
||||
cheaper, pricier, _, pricierCost := orderedResponseBillingModels(t, svc.billingService, tokens, "gpt-5.1", "gpt-5.5")
|
||||
cheaper, pricier, _, pricierCost := orderedResponseBillingModels(t, svc.billingService, tokens, openAICheapFixtureModel, openAIPriceyFixtureModel)
|
||||
|
||||
err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
|
||||
Result: &OpenAIForwardResult{
|
||||
@@ -307,6 +323,135 @@ func TestOpenAIGatewayServiceRecordUsage_ResponseModelSafeFallbacks(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
// --- 准入规则本身 ---
|
||||
|
||||
func TestResponseModelBillingDeclaration(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
source string
|
||||
model string
|
||||
conflict bool
|
||||
mediaBilled bool
|
||||
want string
|
||||
}{
|
||||
{name: "opted_in_and_clean", source: BillingModelSourceResponse, model: " claude-sonnet-4 ", want: "claude-sonnet-4"},
|
||||
{name: "other_source_never_looks_at_response", source: BillingModelSourceChannelMapped, model: "claude-sonnet-4"},
|
||||
{name: "empty_source_never_looks_at_response", source: "", model: "claude-sonnet-4"},
|
||||
{name: "upstream_source_never_looks_at_response", source: BillingModelSourceUpstream, model: "claude-sonnet-4"},
|
||||
{name: "in_stream_conflict_rejected", source: BillingModelSourceResponse, model: "claude-sonnet-4", conflict: true},
|
||||
{name: "media_billed_request_rejected", source: BillingModelSourceResponse, model: "claude-sonnet-4", mediaBilled: true},
|
||||
{name: "blank_declaration_rejected", source: BillingModelSourceResponse, model: " "},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
require.Equal(t, tt.want, responseModelBillingDeclaration(tt.source, tt.model, tt.conflict, tt.mediaBilled))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 上游自报的模型名是外部输入。GetModelPricing 的系列兜底会给任意含 "haiku" 的名字
|
||||
// 返回最便宜的系列价,因此计费准入必须走"确定性识别",否则上游随手编一个名字就能
|
||||
// 把账单压到地板价。本用例把这个差异钉死。
|
||||
func TestBillingServiceHasIdentifiedTokenPricing_RejectsFamilyGuesses(t *testing.T) {
|
||||
t.Parallel()
|
||||
billing := newGatewayRecordUsageServiceForTest(
|
||||
&openAIRecordUsageLogRepoStub{}, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{},
|
||||
).billingService
|
||||
|
||||
require.True(t, billing.HasIdentifiedTokenPricing("claude-sonnet-4"))
|
||||
require.True(t, billing.HasIdentifiedTokenPricing(" CLAUDE-SONNET-4 "), "识别应当忽略大小写与空白")
|
||||
require.True(t, billing.HasIdentifiedTokenPricing("gpt-5.4-nano"))
|
||||
|
||||
const forged = "totally-made-up-haiku-v9"
|
||||
if _, err := billing.GetModelPricing(forged); err == nil {
|
||||
// 这正是本函数存在的理由:宽松查价对编造的名字也会成功。
|
||||
require.False(t, billing.HasIdentifiedTokenPricing(forged),
|
||||
"family-guessed pricing must not qualify a model as a billing basis")
|
||||
}
|
||||
require.False(t, billing.HasIdentifiedTokenPricing(""))
|
||||
require.False(t, billing.HasIdentifiedTokenPricing("zz-unpriced-response-model"))
|
||||
}
|
||||
|
||||
func TestGatewayServiceRecordUsage_ResponseModelRejectsUnidentifiedFamilyName(t *testing.T) {
|
||||
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
|
||||
userRepo := &openAIRecordUsageUserRepoStub{}
|
||||
svc := newGatewayRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{})
|
||||
tokens := UsageTokens{InputTokens: 100, OutputTokens: 50}
|
||||
const forged = "totally-made-up-haiku-v9"
|
||||
|
||||
baselineCost, err := svc.billingService.CalculateCost(anthropicPriceyFixtureModel, tokens, 1.1)
|
||||
require.NoError(t, err)
|
||||
// 前提:这个编造的名字确实能被宽松查价算出更低的费用——正是必须被拒绝的那条路径。
|
||||
forgedCost, err := svc.billingService.CalculateCost(forged, tokens, 1.1)
|
||||
require.NoError(t, err)
|
||||
require.Less(t, forgedCost.TotalCost, baselineCost.TotalCost)
|
||||
|
||||
err = svc.RecordUsage(context.Background(), &RecordUsageInput{
|
||||
Result: &ForwardResult{
|
||||
RequestID: "gateway_response_model_forged_family_name",
|
||||
Usage: ClaudeUsage{InputTokens: 100, OutputTokens: 50},
|
||||
Model: anthropicPriceyFixtureModel,
|
||||
UpstreamResponseModel: forged,
|
||||
Duration: time.Second,
|
||||
},
|
||||
APIKey: &APIKey{ID: 501, Quota: 100},
|
||||
User: &User{ID: 601},
|
||||
Account: &Account{ID: 701},
|
||||
ChannelUsageFields: ChannelUsageFields{
|
||||
ChannelID: 9,
|
||||
OriginalModel: anthropicPriceyFixtureModel,
|
||||
ChannelMappedModel: anthropicPriceyFixtureModel,
|
||||
BillingModelSource: BillingModelSourceResponse,
|
||||
},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, usageRepo.lastLog)
|
||||
require.InDelta(t, baselineCost.ActualCost, usageRepo.lastLog.ActualCost, 1e-12)
|
||||
require.InDelta(t, baselineCost.ActualCost, userRepo.lastAmount, 1e-12)
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayServiceRecordUsage_ResponseModelRejectsUnidentifiedFamilyName(t *testing.T) {
|
||||
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
|
||||
userRepo := &openAIRecordUsageUserRepoStub{}
|
||||
svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{}, nil)
|
||||
tokens := UsageTokens{InputTokens: 20, OutputTokens: 10}
|
||||
const forged = "totally-made-up-haiku-v9"
|
||||
|
||||
baselineCost, err := svc.billingService.CalculateCost(openAIPriceyFixtureModel, tokens, 1.1)
|
||||
require.NoError(t, err)
|
||||
forgedCost, err := svc.billingService.CalculateCost(forged, tokens, 1.1)
|
||||
require.NoError(t, err)
|
||||
require.Less(t, forgedCost.TotalCost, baselineCost.TotalCost)
|
||||
|
||||
err = svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
|
||||
Result: &OpenAIForwardResult{
|
||||
RequestID: "openai_response_model_forged_family_name",
|
||||
Model: openAIPriceyFixtureModel,
|
||||
UpstreamModel: openAIPriceyFixtureModel,
|
||||
UpstreamResponseModel: forged,
|
||||
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: openAIPriceyFixtureModel,
|
||||
ChannelMappedModel: openAIPriceyFixtureModel,
|
||||
BillingModelSource: BillingModelSourceResponse,
|
||||
},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, usageRepo.lastLog)
|
||||
require.InDelta(t, baselineCost.ActualCost, usageRepo.lastLog.ActualCost, 1e-12)
|
||||
require.InDelta(t, baselineCost.ActualCost, userRepo.lastAmount, 1e-12)
|
||||
}
|
||||
|
||||
// --- 渠道配置透传 ---
|
||||
|
||||
func TestToUsageFields_ResponseModelSourcePassesThrough(t *testing.T) {
|
||||
|
||||
@@ -14,8 +14,12 @@ const (
|
||||
|
||||
// upstreamResponseModelObserver tracks one forwarding attempt (or one WS turn).
|
||||
// A terminal declaration wins over an earlier declaration; otherwise the first
|
||||
// declaration is retained. Conflicts are diagnostic only and never affect the
|
||||
// forwarding or billing path.
|
||||
// declaration is retained. Observation never affects the forwarding path.
|
||||
//
|
||||
// Billing normally ignores the observed model as well; the only exception is a
|
||||
// channel explicitly configured with billing_model_source = response_model,
|
||||
// where a conflict flag makes billing fall back to the baseline model
|
||||
// (see responseModelBillingDeclaration).
|
||||
type upstreamResponseModelObserver struct {
|
||||
first string
|
||||
terminal string
|
||||
|
||||
@@ -144,6 +144,8 @@ export default {
|
||||
billingModelSourceRequested: 'Bill by requested model',
|
||||
billingModelSourceUpstream: 'Bill by final upstream model',
|
||||
billingModelSourceResponse: 'Bill by upstream response model',
|
||||
billingModelSourceResponseWarning:
|
||||
'Pricing follows the model declared by the upstream response. It can only lower the charge, never raise it — only enable this for upstreams you trust.',
|
||||
billingModelSourceHint: 'Controls which model name is used for pricing lookup',
|
||||
selectedCount: '{count} selected',
|
||||
searchGroups: 'Search groups...',
|
||||
|
||||
@@ -144,6 +144,7 @@ export default {
|
||||
billingModelSourceRequested: '以请求模型计费',
|
||||
billingModelSourceUpstream: '以最终模型计费',
|
||||
billingModelSourceResponse: '按上游响应模型计费',
|
||||
billingModelSourceResponseWarning: '计费基准以上游响应自报的模型为准。该模式只会降低费用、不会抬高,但请仅对可信的上游启用。',
|
||||
billingModelSourceHint: '控制使用哪个模型名称进行定价查找',
|
||||
selectedCount: '已选 {count} 个',
|
||||
searchGroups: '搜索分组...',
|
||||
|
||||
@@ -226,6 +226,9 @@
|
||||
<p class="mt-1 text-xs text-gray-400">
|
||||
{{ t('admin.channels.form.billingModelSourceHint', 'Controls which model name is used for pricing lookup') }}
|
||||
</p>
|
||||
<p v-if="form.billing_model_source === 'response_model'" class="mt-1 text-xs text-amber-500">
|
||||
{{ t('admin.channels.form.billingModelSourceResponseWarning', 'Pricing follows the model declared by the upstream response. It can only lower the charge, never raise it — only enable this for upstreams you trust.') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Platform Management -->
|
||||
|
||||
Reference in New Issue
Block a user