diff --git a/backend/internal/handler/admin/channel_handler.go b/backend/internal/handler/admin/channel_handler.go index f5442448c8..ade8f0c95c 100644 --- a/backend/internal/handler/admin/channel_handler.go +++ b/backend/internal/handler/admin/channel_handler.go @@ -33,7 +33,7 @@ type createChannelRequest struct { GroupIDs []int64 `json:"group_ids"` ModelPricing []channelModelPricingRequest `json:"model_pricing"` ModelMapping map[string]map[string]string `json:"model_mapping"` - BillingModelSource string `json:"billing_model_source" binding:"omitempty,oneof=requested upstream channel_mapped"` + BillingModelSource string `json:"billing_model_source" binding:"omitempty,oneof=requested upstream channel_mapped response_model"` RestrictModels bool `json:"restrict_models"` Features string `json:"features"` FeaturesConfig map[string]any `json:"features_config"` @@ -48,7 +48,7 @@ type updateChannelRequest struct { GroupIDs *[]int64 `json:"group_ids"` ModelPricing *[]channelModelPricingRequest `json:"model_pricing"` ModelMapping map[string]map[string]string `json:"model_mapping"` - BillingModelSource string `json:"billing_model_source" binding:"omitempty,oneof=requested upstream channel_mapped"` + BillingModelSource string `json:"billing_model_source" binding:"omitempty,oneof=requested upstream channel_mapped response_model"` RestrictModels *bool `json:"restrict_models"` Features *string `json:"features"` FeaturesConfig map[string]any `json:"features_config"` diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index 2b11fc978d..96233b7ae9 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -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) { // 标准化模型名称(转小写) diff --git a/backend/internal/service/channel.go b/backend/internal/service/channel.go index 4a3bdf304d..5ed834eeca 100644 --- a/backend/internal/service/channel.go +++ b/backend/internal/service/channel.go @@ -39,6 +39,10 @@ const ( BillingModelSourceRequested = "requested" BillingModelSourceUpstream = "upstream" BillingModelSourceChannelMapped = "channel_mapped" + // BillingModelSourceResponse bills by a trusted model declaration observed + // in the successful upstream response. It is deliberately distinct from + // "upstream", which means the model sent to the provider. + BillingModelSourceResponse = "response_model" ) // Channel 渠道实体 @@ -47,7 +51,7 @@ type Channel struct { Name string Description string Status string - BillingModelSource string // "requested", "upstream", or "channel_mapped" + BillingModelSource string // "requested", "upstream", "channel_mapped", or "response_model" RestrictModels bool // 是否限制模型(仅允许定价列表中的模型) Features string // 渠道特性描述(JSON 数组),用于支付页面展示 FeaturesConfig map[string]any // 渠道功能配置(如 web search emulation) diff --git a/backend/internal/service/channel_service.go b/backend/internal/service/channel_service.go index 934f06d976..e56f699a8e 100644 --- a/backend/internal/service/channel_service.go +++ b/backend/internal/service/channel_service.go @@ -108,7 +108,7 @@ type ChannelMappingResult struct { MappedModel string // 映射后的模型名(无映射时等于原始模型名) ChannelID int64 // 渠道 ID(0 = 无渠道关联) Mapped bool // 是否发生了映射 - BillingModelSource string // 计费模型来源("requested" / "upstream" / "channel_mapped") + BillingModelSource string // 计费模型来源("requested" / "upstream" / "channel_mapped" / "response_model") } // BuildModelMappingChain 根据映射结果和上游实际模型构建映射链描述。 diff --git a/backend/internal/service/gateway_channel_restriction_test.go b/backend/internal/service/gateway_channel_restriction_test.go index 3a2ad2ff72..0e26a00c02 100644 --- a/backend/internal/service/gateway_channel_restriction_test.go +++ b/backend/internal/service/gateway_channel_restriction_test.go @@ -29,6 +29,12 @@ func TestBillingModelForRestriction_Upstream(t *testing.T) { require.Equal(t, "", got, "upstream should return empty (per-account check needed)") } +func TestBillingModelForRestriction_ResponseModelUsesMappedPrecheck(t *testing.T) { + t.Parallel() + got := billingModelForRestriction(BillingModelSourceResponse, "claude-fable-5", "claude-fable-5") + require.Equal(t, "claude-fable-5", got) +} + func TestBillingModelForRestriction_Empty(t *testing.T) { t.Parallel() got := billingModelForRestriction("", "claude-sonnet-4-5", "claude-sonnet-4-6") diff --git a/backend/internal/service/gateway_forward.go b/backend/internal/service/gateway_forward.go index 1c1d41a711..d10cb1ea3b 100644 --- a/backend/internal/service/gateway_forward.go +++ b/backend/internal/service/gateway_forward.go @@ -928,6 +928,10 @@ func billingModelForRestriction(source, requestedModel, channelMappedModel strin return requestedModel case BillingModelSourceUpstream: return "" + case BillingModelSourceResponse: + // The response is not available during dispatch; use mapped pricing + // for restriction prechecks and decide billing after the response. + return channelMappedModel case BillingModelSourceChannelMapped: return channelMappedModel default: diff --git a/backend/internal/service/gateway_usage_billing.go b/backend/internal/service/gateway_usage_billing.go index ee212afde1..a5998f3595 100644 --- a/backend/internal/service/gateway_usage_billing.go +++ b/backend/internal/service/gateway_usage_billing.go @@ -693,6 +693,80 @@ type recordUsageCoreInput struct { ChannelUsageFields } +// responseModelBillingCostEpsilon 吸收两次成本计算之间的浮点末位误差, +// 避免同价模型因浮点误差被判成"更贵"而白白放弃采纳。 +const responseModelBillingCostEpsilon = 1e-12 + +// responseModelBillingDeclaration 返回可用于计费的上游响应模型;返回空字符串表示 +// 必须沿用基线计费模型。两条计费主干(Anthropic 系 / OpenAI 系)共用本准入判断。 +// +// 渠道把 billing_model_source 设为 response_model,等于把"按哪个模型计价"的一部分 +// 决定权交给上游,因此准入条件必须收紧: +// - 只在渠道显式开启该模式时生效,其余模式一律不看响应模型; +// - 一次请求内出现过互相冲突的模型声明时不采纳(无法确定上游究竟服务了哪个模型); +// - 图片 / 视频 / 网页搜索 / 语音 / 搜索附加费这类按次按量计费的请求不采纳:它们按张、 +// 按秒、按次定价,与本模式的 token 定价准入检查不是同一套价格表,混用会让一个只验过 +// token 价的模型名去决定媒体单价。新增按次计费形态时必须同步扩这个入参。 +// +// 调用方还必须额外满足两条:模型能被价格表确定性识别(见 +// hasIdentifiedResponseModelPricing / hasIdentifiedOpenAIResponsePricing),以及通过 +// responseModelBillingAdoptable 的成本准入。 +func responseModelBillingDeclaration(source, responseModel string, conflict, mediaBilled bool) string { + if source != BillingModelSourceResponse || conflict || mediaBilled { + return "" + } + 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) { + 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,6 +839,27 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage // 计算费用 cost := s.calculateRecordUsageCost(ctx, result, apiKey, billingModel, multiplier, imageMultiplier, opts) + // response_model:按上游成功响应自报的模型计费(渠道显式开启才生效)。 + // 采纳条件见 responseModelBillingDeclaration + hasIdentifiedResponseModelPricing + // + responseModelBillingAdoptable。任一条件不满足都静默回落基线,即开启本模式前的 + // 既有行为。响应模型与基线同名时直接跳过:重算必然同价,白跑一次定价解析。 + if responseModel := responseModelBillingDeclaration( + input.BillingModelSource, + result.UpstreamResponseModel, + result.UpstreamResponseModelConflict, + 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 + } + } + } // 判断计费方式:订阅模式 vs 余额模式 isSubscriptionBilling := subscription != nil && apiKey.Group != nil && apiKey.Group.IsSubscriptionType() @@ -929,6 +1024,22 @@ func (s *GatewayService) hasResolvableTokenPricing(ctx context.Context, model st return err == nil } +// hasIdentifiedResponseModelPricing 判断上游自报的响应模型是否可以作为计费基准, +// 并回传它是否解析到了渠道级定价(供 responseModelBillingAdoptable 的跨定价源守卫使用, +// 避免为此再解析一次)。 +// 与 hasResolvableTokenPricing 的区别是刻意更严:只接受管理员为该模型显式配置的 +// 渠道定价,或价格表中能被确定性识别的条目;不接受按子串猜出来的系列兜底价。 +// 详见 responseModelBillingDeclaration 的说明。 +func (s *GatewayService) hasIdentifiedResponseModelPricing(ctx context.Context, model string, apiKey *APIKey) (identified bool, channelPriced bool) { + if strings.TrimSpace(model) == "" { + return false, false + } + if s.resolveChannelPricing(ctx, model, apiKey) != nil { + return true, true + } + return s.billingService.HasIdentifiedTokenPricing(model), false +} + // resolveChannelPricing 检查指定模型是否存在渠道级别定价。 // 返回非 nil 的 ResolvedPricing 表示有渠道定价,nil 表示走默认定价路径。 func (s *GatewayService) resolveChannelPricing(ctx context.Context, billingModel string, apiKey *APIKey) *ResolvedPricing { diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go index 1bba5fce72..c2de3c6472 100644 --- a/backend/internal/service/openai_gateway_usage.go +++ b/backend/internal/service/openai_gateway_usage.go @@ -240,6 +240,36 @@ 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:按上游成功响应自报的模型计费(渠道显式开启才生效)。 + // 采纳条件见 responseModelBillingDeclaration + hasIdentifiedOpenAIResponsePricing + // + responseModelBillingAdoptable。任一条件不满足都静默回落基线,即开启本模式前的 + // 既有行为。响应模型与基线同名时直接跳过:重算必然同价,白跑一次定价解析。 + baselineBillingModel := firstUsageBillingModel(billingModels) + if responseModel := responseModelBillingDeclaration( + input.BillingModelSource, + result.UpstreamResponseModel, + result.UpstreamResponseModelConflict, + 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 + } + } + } // Determine billing type isSubscriptionBilling := subscription != nil && apiKey.Group != nil && apiKey.Group.IsSubscriptionType() @@ -433,6 +463,23 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec return nil } +// hasIdentifiedOpenAIResponsePricing 判断上游自报的响应模型是否可以作为计费基准, +// 并回传它是否解析到了渠道级定价(供 responseModelBillingAdoptable 的跨定价源守卫使用, +// 避免为此再解析一次)。 +// 只接受管理员为该模型显式配置的渠道定价,或价格表中能被确定性识别的条目; +// 刻意不接受按子串猜出来的系列兜底价,否则上游随便编一个含 "haiku" 的名字就能把 +// 计费拉到最便宜的系列价上。详见 responseModelBillingDeclaration。 +func (s *OpenAIGatewayService) hasIdentifiedOpenAIResponsePricing(ctx context.Context, model string, apiKey *APIKey) (identified bool, channelPriced bool) { + model = strings.TrimSpace(model) + if model == "" { + return false, false + } + if s.resolveOpenAIChannelPricing(ctx, model, apiKey) != nil { + return true, true + } + return s.billingService.HasIdentifiedTokenPricing(model), false +} + func (s *OpenAIGatewayService) calculateOpenAIRecordUsageCost( ctx context.Context, result *OpenAIForwardResult, diff --git a/backend/internal/service/pricing_service.go b/backend/internal/service/pricing_service.go index d0dce84f4c..f7733ae001 100644 --- a/backend/internal/service/pricing_service.go +++ b/backend/internal/service/pricing_service.go @@ -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, diff --git a/backend/internal/service/response_model_billing_test.go b/backend/internal/service/response_model_billing_test.go new file mode 100644 index 0000000000..9194a1684e --- /dev/null +++ b/backend/internal/service/response_model_billing_test.go @@ -0,0 +1,596 @@ +//go:build unit + +package service + +import ( + "context" + "testing" + "time" + + "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) { + t.Helper() + costA, err := svc.CalculateCost(a, tokens, 1.1) + require.NoError(t, err) + 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 + } + return b, a, costB, costA +} + +// --- Anthropic gateway (GatewayService.RecordUsage) --- + +func TestGatewayServiceRecordUsage_ResponseModelBillsCheaperResponseModel(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + svc := newGatewayRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{}) + tokens := UsageTokens{InputTokens: 100, OutputTokens: 50} + cheaper, pricier, cheaperCost, _ := orderedResponseBillingModels(t, svc.billingService, tokens, anthropicCheapFixtureModel, anthropicPriceyFixtureModel) + + err := svc.RecordUsage(context.Background(), &RecordUsageInput{ + Result: &ForwardResult{ + RequestID: "gateway_response_model_downgrade", + Usage: ClaudeUsage{InputTokens: 100, OutputTokens: 50}, + Model: pricier, + UpstreamResponseModel: cheaper, // upstream declared a runtime downgrade + 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) + require.InDelta(t, cheaperCost.ActualCost, usageRepo.lastLog.ActualCost, 1e-12) + require.InDelta(t, cheaperCost.ActualCost, userRepo.lastAmount, 1e-12) + require.True(t, usageRepo.lastLog.ActualCost > 0, "cost must not be zero") + // 审计链完整保留:请求/发送模型不因计费切换被改写,响应模型与 mismatch 记录在案。 + require.Equal(t, pricier, usageRepo.lastLog.Model) + require.Equal(t, pricier, usageRepo.lastLog.RequestedModel) + require.NotNil(t, usageRepo.lastLog.UpstreamResponseModel) + require.Equal(t, cheaper, *usageRepo.lastLog.UpstreamResponseModel) + require.NotNil(t, usageRepo.lastLog.UpstreamModelMismatch) + require.True(t, *usageRepo.lastLog.UpstreamModelMismatch) +} + +func TestGatewayServiceRecordUsage_ResponseModelRejectsPricierResponseModel(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + svc := newGatewayRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{}) + tokens := UsageTokens{InputTokens: 100, OutputTokens: 50} + cheaper, pricier, cheaperCost, _ := orderedResponseBillingModels(t, svc.billingService, tokens, anthropicCheapFixtureModel, anthropicPriceyFixtureModel) + + err := svc.RecordUsage(context.Background(), &RecordUsageInput{ + Result: &ForwardResult{ + RequestID: "gateway_response_model_forged_upgrade", + Usage: ClaudeUsage{InputTokens: 100, OutputTokens: 50}, + Model: cheaper, + UpstreamResponseModel: pricier, // forged/upgraded declaration must not raise cost + Duration: time.Second, + }, + APIKey: &APIKey{ID: 501, Quota: 100}, + User: &User{ID: 601}, + Account: &Account{ID: 701}, + ChannelUsageFields: ChannelUsageFields{ + ChannelID: 9, + OriginalModel: cheaper, + ChannelMappedModel: cheaper, + BillingModelSource: BillingModelSourceResponse, + }, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.InDelta(t, cheaperCost.ActualCost, usageRepo.lastLog.ActualCost, 1e-12) + require.InDelta(t, cheaperCost.ActualCost, userRepo.lastAmount, 1e-12) +} + +func TestGatewayServiceRecordUsage_ResponseModelSafeFallbacks(t *testing.T) { + tests := []struct { + name string + responseModel func(cheaper string) string + conflict bool + source string + }{ + { + name: "in_stream_conflict_falls_back_to_baseline", + responseModel: func(cheaper string) string { return cheaper }, + conflict: true, + source: BillingModelSourceResponse, + }, + { + name: "empty_response_model_falls_back_to_baseline", + responseModel: func(string) string { return "" }, + source: BillingModelSourceResponse, + }, + { + name: "unpriced_response_model_falls_back_to_baseline", + responseModel: func(string) string { return "zz-unpriced-response-model" }, + source: BillingModelSourceResponse, + }, + { + name: "default_channel_mapped_mode_ignores_response_model", + responseModel: func(cheaper string) string { return cheaper }, + source: BillingModelSourceChannelMapped, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(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) + + err := svc.RecordUsage(context.Background(), &RecordUsageInput{ + Result: &ForwardResult{ + RequestID: "gateway_response_model_fallback_" + tt.name, + Usage: ClaudeUsage{InputTokens: 100, OutputTokens: 50}, + Model: pricier, + UpstreamResponseModel: tt.responseModel(cheaper), + UpstreamResponseModelConflict: tt.conflict, + 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: tt.source, + }, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.InDelta(t, pricierCost.ActualCost, usageRepo.lastLog.ActualCost, 1e-12) + require.InDelta(t, pricierCost.ActualCost, userRepo.lastAmount, 1e-12) + }) + } +} + +// --- OpenAI gateway (OpenAIGatewayService.RecordUsage) --- + +func TestOpenAIGatewayServiceRecordUsage_ResponseModelBillsCheaperResponseModel(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{}, nil) + tokens := UsageTokens{InputTokens: 20, OutputTokens: 10} + cheaper, pricier, cheaperCost, _ := orderedResponseBillingModels(t, svc.billingService, tokens, openAICheapFixtureModel, openAIPriceyFixtureModel) + + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "openai_response_model_downgrade", + Model: pricier, + UpstreamModel: pricier, + UpstreamResponseModel: cheaper, + 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) + require.InDelta(t, cheaperCost.ActualCost, usageRepo.lastLog.ActualCost, 1e-12) + require.InDelta(t, cheaperCost.ActualCost, userRepo.lastAmount, 1e-12) + require.True(t, usageRepo.lastLog.ActualCost > 0, "cost must not be zero") + // 审计链完整保留。 + require.Equal(t, pricier, usageRepo.lastLog.Model) + require.NotNil(t, usageRepo.lastLog.UpstreamResponseModel) + require.Equal(t, cheaper, *usageRepo.lastLog.UpstreamResponseModel) + require.NotNil(t, usageRepo.lastLog.UpstreamModelMismatch) + require.True(t, *usageRepo.lastLog.UpstreamModelMismatch) +} + +func TestOpenAIGatewayServiceRecordUsage_ResponseModelRejectsPricierResponseModel(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{}, nil) + tokens := UsageTokens{InputTokens: 20, OutputTokens: 10} + cheaper, pricier, cheaperCost, _ := orderedResponseBillingModels(t, svc.billingService, tokens, openAICheapFixtureModel, openAIPriceyFixtureModel) + + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "openai_response_model_forged_upgrade", + Model: cheaper, + UpstreamModel: cheaper, + UpstreamResponseModel: pricier, + 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: cheaper, + ChannelMappedModel: cheaper, + BillingModelSource: BillingModelSourceResponse, + }, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.InDelta(t, cheaperCost.ActualCost, usageRepo.lastLog.ActualCost, 1e-12) + require.InDelta(t, cheaperCost.ActualCost, userRepo.lastAmount, 1e-12) +} + +func TestOpenAIGatewayServiceRecordUsage_ResponseModelSafeFallbacks(t *testing.T) { + tests := []struct { + name string + responseModel func(cheaper string) string + conflict bool + source string + }{ + { + name: "in_stream_conflict_falls_back_to_baseline", + responseModel: func(cheaper string) string { return cheaper }, + conflict: true, + source: BillingModelSourceResponse, + }, + { + name: "empty_response_model_falls_back_to_baseline", + responseModel: func(string) string { return "" }, + source: BillingModelSourceResponse, + }, + { + name: "unpriced_response_model_falls_back_to_baseline", + responseModel: func(string) string { return "zz-unpriced-response-model" }, + source: BillingModelSourceResponse, + }, + { + name: "default_channel_mapped_mode_ignores_response_model", + responseModel: func(cheaper string) string { return cheaper }, + source: BillingModelSourceChannelMapped, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(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) + + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "openai_response_model_fallback_" + tt.name, + Model: pricier, + UpstreamModel: pricier, + UpstreamResponseModel: tt.responseModel(cheaper), + UpstreamResponseModelConflict: tt.conflict, + 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: tt.source, + }, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.InDelta(t, pricierCost.ActualCost, usageRepo.lastLog.ActualCost, 1e-12) + require.InDelta(t, pricierCost.ActualCost, userRepo.lastAmount, 1e-12) + }) + } +} + +// --- 准入规则本身 --- + +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 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) { + r := ChannelMappingResult{ + MappedModel: "claude-fable-5", + ChannelID: 4, + Mapped: false, + BillingModelSource: BillingModelSourceResponse, + } + fields := r.ToUsageFields("claude-fable-5", "claude-fable-5") + require.Equal(t, int64(4), fields.ChannelID) + require.Equal(t, BillingModelSourceResponse, fields.BillingModelSource) +} diff --git a/backend/internal/service/upstream_response_model.go b/backend/internal/service/upstream_response_model.go index cc282c228f..81869c07d9 100644 --- a/backend/internal/service/upstream_response_model.go +++ b/backend/internal/service/upstream_response_model.go @@ -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 diff --git a/frontend/src/constants/channel.ts b/frontend/src/constants/channel.ts index c08f480078..6b54b47df0 100644 --- a/frontend/src/constants/channel.ts +++ b/frontend/src/constants/channel.ts @@ -16,7 +16,9 @@ export type BillingMode = export const BILLING_MODEL_SOURCE_REQUESTED = 'requested' as const export const BILLING_MODEL_SOURCE_UPSTREAM = 'upstream' as const export const BILLING_MODEL_SOURCE_CHANNEL_MAPPED = 'channel_mapped' as const +export const BILLING_MODEL_SOURCE_RESPONSE = 'response_model' as const export type BillingModelSource = | typeof BILLING_MODEL_SOURCE_REQUESTED | typeof BILLING_MODEL_SOURCE_UPSTREAM | typeof BILLING_MODEL_SOURCE_CHANNEL_MAPPED + | typeof BILLING_MODEL_SOURCE_RESPONSE diff --git a/frontend/src/i18n/locales/en/admin/channels.ts b/frontend/src/i18n/locales/en/admin/channels.ts index 0a52cabdfe..6b26e21da9 100644 --- a/frontend/src/i18n/locales/en/admin/channels.ts +++ b/frontend/src/i18n/locales/en/admin/channels.ts @@ -143,6 +143,7 @@ export default { billingModelSourceChannelMapped: 'Bill by channel-mapped model', billingModelSourceRequested: 'Bill by requested model', billingModelSourceUpstream: 'Bill by final upstream model', + billingModelSourceResponse: 'Bill by upstream response model', billingModelSourceHint: 'Controls which model name is used for pricing lookup', selectedCount: '{count} selected', searchGroups: 'Search groups...', diff --git a/frontend/src/i18n/locales/zh/admin/channels.ts b/frontend/src/i18n/locales/zh/admin/channels.ts index 45fbf7029d..3da00a3120 100644 --- a/frontend/src/i18n/locales/zh/admin/channels.ts +++ b/frontend/src/i18n/locales/zh/admin/channels.ts @@ -143,6 +143,7 @@ export default { billingModelSourceChannelMapped: '以渠道映射后的模型计费', billingModelSourceRequested: '以请求模型计费', billingModelSourceUpstream: '以最终模型计费', + billingModelSourceResponse: '按上游响应模型计费', billingModelSourceHint: '控制使用哪个模型名称进行定价查找', selectedCount: '已选 {count} 个', searchGroups: '搜索分组...', diff --git a/frontend/src/views/admin/ChannelsView.vue b/frontend/src/views/admin/ChannelsView.vue index befc356f2b..9dceb5362a 100644 --- a/frontend/src/views/admin/ChannelsView.vue +++ b/frontend/src/views/admin/ChannelsView.vue @@ -713,7 +713,8 @@ const statusEditOptions = computed(() => [ const billingModelSourceOptions = computed(() => [ { value: 'channel_mapped', label: t('admin.channels.form.billingModelSourceChannelMapped', 'Bill by channel-mapped model') }, { value: 'requested', label: t('admin.channels.form.billingModelSourceRequested', 'Bill by requested model') }, - { value: 'upstream', label: t('admin.channels.form.billingModelSourceUpstream', 'Bill by final upstream model') } + { value: 'upstream', label: t('admin.channels.form.billingModelSourceUpstream', 'Bill by final upstream model') }, + { value: 'response_model', label: t('admin.channels.form.billingModelSourceResponse', 'Bill by upstream response model') } ]) // ── State ──