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:
shaw
2026-08-10 18:45:15 +08:00
parent 9096492b55
commit b689e5b401
9 changed files with 336 additions and 51 deletions
@@ -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(
+44 -10
View File
@@ -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 -->