From 9096492b559909d6f8dec54de3e3d64a870809e1 Mon Sep 17 00:00:00 2001 From: pigzwy Date: Mon, 10 Aug 2026 06:04:13 +0000 Subject: [PATCH 1/4] feat(billing): support safe upstream response model billing --- .../internal/handler/admin/channel_handler.go | 4 +- backend/internal/service/channel.go | 6 +- backend/internal/service/channel_service.go | 2 +- .../gateway_channel_restriction_test.go | 6 + backend/internal/service/gateway_forward.go | 4 + .../internal/service/gateway_usage_billing.go | 14 + .../internal/service/openai_gateway_usage.go | 32 ++ .../service/response_model_billing_test.go | 322 ++++++++++++++++++ frontend/src/constants/channel.ts | 2 + .../src/i18n/locales/en/admin/channels.ts | 1 + .../src/i18n/locales/zh/admin/channels.ts | 1 + frontend/src/views/admin/ChannelsView.vue | 3 +- 12 files changed, 392 insertions(+), 5 deletions(-) create mode 100644 backend/internal/service/response_model_billing_test.go 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/channel.go b/backend/internal/service/channel.go index 4a3bdf304d..40f3c56530 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..558e03fc49 100644 --- a/backend/internal/service/gateway_usage_billing.go +++ b/backend/internal/service/gateway_usage_billing.go @@ -765,6 +765,20 @@ 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 + } + } + } // 判断计费方式:订阅模式 vs 余额模式 isSubscriptionBilling := subscription != nil && apiKey.Group != nil && apiKey.Group.IsSubscriptionType() diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go index 1bba5fce72..8bc79f068d 100644 --- a/backend/internal/service/openai_gateway_usage.go +++ b/backend/internal/service/openai_gateway_usage.go @@ -240,6 +240,23 @@ 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 + } + } + } // Determine billing type isSubscriptionBilling := subscription != nil && apiKey.Group != nil && apiKey.Group.IsSubscriptionType() @@ -433,6 +450,21 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec return nil } +func (s *OpenAIGatewayService) hasResolvableOpenAIResponsePricing(ctx context.Context, model string, apiKey *APIKey) bool { + model = strings.TrimSpace(model) + if model == "" { + return false + } + if s.resolveOpenAIChannelPricing(ctx, model, apiKey) != nil { + return true + } + if s.billingService == nil { + return false + } + _, err := s.billingService.GetModelPricing(model) + return err == nil +} + func (s *OpenAIGatewayService) calculateOpenAIRecordUsageCost( ctx context.Context, result *OpenAIForwardResult, 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..7016f40536 --- /dev/null +++ b/backend/internal/service/response_model_billing_test.go @@ -0,0 +1,322 @@ +//go:build unit + +package service + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// 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) + 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, "claude-sonnet-4", "claude-opus-4") + + 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, "claude-sonnet-4", "claude-opus-4") + + 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, "claude-sonnet-4", "claude-opus-4") + + 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, "gpt-5.1", "gpt-5.5") + + 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, "gpt-5.1", "gpt-5.5") + + 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, "gpt-5.1", "gpt-5.5") + + 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 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/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 ── From b689e5b401431797b9f0373191547ef44a9f800a Mon Sep 17 00:00:00 2001 From: shaw Date: Mon, 10 Aug 2026 15:12:06 +0800 Subject: [PATCH 2/4] fix(billing): harden response-model billing and repair its test fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按上游响应模型计费的准入过宽、且自带用例必然失败,本次一并修复。 严格化准入 - 新增 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。 - 夹具增加"必须可被确定性识别"的前置断言,避免用例被更靠前的门挡掉而失去判别力。 - 新增:准入规则表驱动用例、可识别性判定用例、编造家族名在两条主干上均被拒的用例。 前端 - 选择该模式时提示"计费基准以上游自报模型为准,只降不升,仅对可信上游启用"。 --- backend/internal/service/billing_service.go | 24 +++ .../internal/service/gateway_usage_billing.go | 90 ++++++++-- .../internal/service/openai_gateway_usage.go | 48 +++--- backend/internal/service/pricing_service.go | 54 ++++-- .../service/response_model_billing_test.go | 157 +++++++++++++++++- .../service/upstream_response_model.go | 8 +- .../src/i18n/locales/en/admin/channels.ts | 2 + .../src/i18n/locales/zh/admin/channels.ts | 1 + frontend/src/views/admin/ChannelsView.vue | 3 + 9 files changed, 336 insertions(+), 51 deletions(-) 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/gateway_usage_billing.go b/backend/internal/service/gateway_usage_billing.go index 558e03fc49..a8ed3dcf8d 100644 --- a/backend/internal/service/gateway_usage_billing.go +++ b/backend/internal/service/gateway_usage_billing.go @@ -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 { diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go index 8bc79f068d..78318a79e3 100644 --- a/backend/internal/service/openai_gateway_usage.go +++ b/backend/internal/service/openai_gateway_usage.go @@ -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( 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 index 7016f40536..451a48b057 100644 --- a/backend/internal/service/response_model_billing_test.go +++ b/backend/internal/service/response_model_billing_test.go @@ -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) { 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/i18n/locales/en/admin/channels.ts b/frontend/src/i18n/locales/en/admin/channels.ts index 6b26e21da9..980401316a 100644 --- a/frontend/src/i18n/locales/en/admin/channels.ts +++ b/frontend/src/i18n/locales/en/admin/channels.ts @@ -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...', diff --git a/frontend/src/i18n/locales/zh/admin/channels.ts b/frontend/src/i18n/locales/zh/admin/channels.ts index 3da00a3120..2b18151ac0 100644 --- a/frontend/src/i18n/locales/zh/admin/channels.ts +++ b/frontend/src/i18n/locales/zh/admin/channels.ts @@ -144,6 +144,7 @@ export default { billingModelSourceRequested: '以请求模型计费', billingModelSourceUpstream: '以最终模型计费', billingModelSourceResponse: '按上游响应模型计费', + billingModelSourceResponseWarning: '计费基准以上游响应自报的模型为准。该模式只会降低费用、不会抬高,但请仅对可信的上游启用。', billingModelSourceHint: '控制使用哪个模型名称进行定价查找', selectedCount: '已选 {count} 个', searchGroups: '搜索分组...', diff --git a/frontend/src/views/admin/ChannelsView.vue b/frontend/src/views/admin/ChannelsView.vue index 9dceb5362a..308b2f05b8 100644 --- a/frontend/src/views/admin/ChannelsView.vue +++ b/frontend/src/views/admin/ChannelsView.vue @@ -226,6 +226,9 @@

{{ t('admin.channels.form.billingModelSourceHint', 'Controls which model name is used for pricing lookup') }}

+

+ {{ 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.') }} +

From 33351c7bc740f2ecf490971f3bdd1940eec4923f Mon Sep 17 00:00:00 2001 From: shaw Date: Mon, 10 Aug 2026 16:42:51 +0800 Subject: [PATCH 3/4] fix(billing): gofmt channel.go and drop the redundant response-model hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - channel.go: 常量块里插入注释后 gofmt 会把 BillingModelSourceResponse 单独成组, 原写法沿用了上一组的对齐空格,CI 的 gofmt 检查因此失败 (internal/service/channel.go:45: File is not properly formatted)。 - 撤掉渠道表单里新加的那条提示:说明本就多余,且"只降不升"只在"相对基线收费" 这个口径下成立,容易被读成"上游返回更贵的模型也不会多收",反而误导。 --- backend/internal/service/channel.go | 2 +- frontend/src/i18n/locales/en/admin/channels.ts | 2 -- frontend/src/i18n/locales/zh/admin/channels.ts | 1 - frontend/src/views/admin/ChannelsView.vue | 3 --- 4 files changed, 1 insertion(+), 7 deletions(-) diff --git a/backend/internal/service/channel.go b/backend/internal/service/channel.go index 40f3c56530..5ed834eeca 100644 --- a/backend/internal/service/channel.go +++ b/backend/internal/service/channel.go @@ -42,7 +42,7 @@ const ( // 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" + BillingModelSourceResponse = "response_model" ) // Channel 渠道实体 diff --git a/frontend/src/i18n/locales/en/admin/channels.ts b/frontend/src/i18n/locales/en/admin/channels.ts index 980401316a..6b26e21da9 100644 --- a/frontend/src/i18n/locales/en/admin/channels.ts +++ b/frontend/src/i18n/locales/en/admin/channels.ts @@ -144,8 +144,6 @@ 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...', diff --git a/frontend/src/i18n/locales/zh/admin/channels.ts b/frontend/src/i18n/locales/zh/admin/channels.ts index 2b18151ac0..3da00a3120 100644 --- a/frontend/src/i18n/locales/zh/admin/channels.ts +++ b/frontend/src/i18n/locales/zh/admin/channels.ts @@ -144,7 +144,6 @@ export default { billingModelSourceRequested: '以请求模型计费', billingModelSourceUpstream: '以最终模型计费', billingModelSourceResponse: '按上游响应模型计费', - billingModelSourceResponseWarning: '计费基准以上游响应自报的模型为准。该模式只会降低费用、不会抬高,但请仅对可信的上游启用。', billingModelSourceHint: '控制使用哪个模型名称进行定价查找', selectedCount: '已选 {count} 个', searchGroups: '搜索分组...', diff --git a/frontend/src/views/admin/ChannelsView.vue b/frontend/src/views/admin/ChannelsView.vue index 308b2f05b8..9dceb5362a 100644 --- a/frontend/src/views/admin/ChannelsView.vue +++ b/frontend/src/views/admin/ChannelsView.vue @@ -226,9 +226,6 @@

{{ t('admin.channels.form.billingModelSourceHint', 'Controls which model name is used for pricing lookup') }}

-

- {{ 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.') }} -

From e5b325e481626933acd7e46371c5b15473d08b8c Mon Sep 17 00:00:00 2001 From: anya Date: Mon, 10 Aug 2026 19:11:47 +0800 Subject: [PATCH 4/4] fix(billing): harden response-model billing admission Three guards on the response_model billing basis, all scoped to the opt-in channel mode so existing channels are unaffected. 1. Per-unit billing gate was stale. Audio (AudioUsage) and the search surcharge (SearchCount) reached the billing paths after this branch was cut; both are priced per unit rather than per token, so they must be excluded like image/video/web-search already are. Audio pricing ignores the model entirely, so the previous code "adopted" a basis switch that changed nothing and emitted a misleading audit log for it. 2. Never zero out a billable request. A catalog entry whose token prices are explicitly 0 still passes the identified-pricing gate (TokenPricingAbsent only means both prices are missing), so an upstream could declare a free model name and drop the bill to zero. Reject a zero (or negative) recomputation whenever the baseline was billable; an already-zero baseline is unaffected. 3. Never cross from channel pricing to the global table. Channel pricing matches exact keys and prefix wildcards and does not strip date suffixes, while the global table's identified lookup does. Upstreams routinely declare dated model IDs (claude-opus-4-5-20251101), so allowing a cross-source comparison would silently bypass an administrator's channel markup on essentially every request. Admins who want a downgrade target discounted can price it explicitly on the channel. Also skip the recomputation entirely when the declared model equals the baseline: it is provably the same cost and only burned a pricing resolve. The identified-pricing helpers now return whether the model resolved to channel pricing so the third guard costs no extra resolve. --- .../internal/service/gateway_usage_billing.go | 73 +++++++--- .../internal/service/openai_gateway_usage.go | 51 ++++--- .../service/response_model_billing_test.go | 129 ++++++++++++++++++ 3 files changed, 211 insertions(+), 42 deletions(-) diff --git a/backend/internal/service/gateway_usage_billing.go b/backend/internal/service/gateway_usage_billing.go index a8ed3dcf8d..a5998f3595 100644 --- a/backend/internal/service/gateway_usage_billing.go +++ b/backend/internal/service/gateway_usage_billing.go @@ -704,13 +704,13 @@ const responseModelBillingCostEpsilon = 1e-12 // 决定权交给上游,因此准入条件必须收紧: // - 只在渠道显式开启该模式时生效,其余模式一律不看响应模型; // - 一次请求内出现过互相冲突的模型声明时不采纳(无法确定上游究竟服务了哪个模型); -// - 图片 / 视频 / 网页搜索这类按次计费的请求不采纳:它们按张、按秒、按次定价, -// 与本模式的 token 定价准入检查不是同一套价格表,混用会让一个只验过 token 价的 -// 模型名去决定媒体单价。 +// - 图片 / 视频 / 网页搜索 / 语音 / 搜索附加费这类按次按量计费的请求不采纳:它们按张、 +// 按秒、按次定价,与本模式的 token 定价准入检查不是同一套价格表,混用会让一个只验过 +// token 价的模型名去决定媒体单价。新增按次计费形态时必须同步扩这个入参。 // // 调用方还必须额外满足两条:模型能被价格表确定性识别(见 -// hasIdentifiedResponseModelPricing / hasIdentifiedOpenAIResponsePricing),以及 -// 重算成本不高于基线成本——上游声明永远不能抬高用户费用。 +// hasIdentifiedResponseModelPricing / hasIdentifiedOpenAIResponsePricing),以及通过 +// responseModelBillingAdoptable 的成本准入。 func responseModelBillingDeclaration(source, responseModel string, conflict, mediaBilled bool) string { if source != BillingModelSourceResponse || conflict || mediaBilled { return "" @@ -718,6 +718,32 @@ func responseModelBillingDeclaration(source, responseModel string, conflict, med return strings.TrimSpace(responseModel) } +// responseModelBillingAdoptable 判定按响应模型重算出的成本能否取代基线成本。 +// 三条不变式,任一不满足都必须沿用基线(即开启本模式前的既有行为): +// +// 1. 不得更贵——上游声明永远不能抬高用户费用;epsilon 吸收两次计算之间的浮点末位误差。 +// 2. 不得把一笔本应计费的请求归零。价格表里存在把 token 价显式写成 0 的条目 +// (TokenPricingAbsent 只在 input/output 价**都缺失**时才为真,显式 0 算"有价"因而 +// 能通过确定性识别那道门),放任归零等于让上游自报一个免费模型名就能白嫖。 +// 基线本身就是 0 时不受影响,采纳与否都不改变金额。 +// 3. 不得把计费从管理员显式配置的渠道定价切到全局价格表。渠道定价查表只做精确键与 +// 前缀通配、**不剥日期后缀**,而全局价格表的确定性识别**会剥** 8 位日期后缀;上游 +// 普遍自报带日期的模型 ID(如 claude-opus-4-5-20251101),若允许跨源比较,渠道加价 +// 会被这类自报名字静默绕过。管理员若确实想让降级目标享受折扣,为它显式配一条渠道 +// 定价即可——那是一次可审计的显式授权。 +func responseModelBillingAdoptable(baseline, response *CostBreakdown, baselineChannelPriced, responseChannelPriced bool) bool { + if baseline == nil || response == nil { + return false + } + if response.TotalCost > baseline.TotalCost+responseModelBillingCostEpsilon { + return false + } + if response.TotalCost <= 0 && baseline.TotalCost > 0 { + return false + } + return !baselineChannelPriced || responseChannelPriced +} + // logResponseModelBillingApplied 记录一次实际生效的响应模型计费切换。 // 本模式下的少收由上游声明驱动,必须留下可审计痕迹;计费基准未变时不记录,避免刷屏。 func logResponseModelBillingApplied(component string, account *Account, requestID, baselineModel, responseModel string, baselineCost, responseCost *CostBreakdown) { @@ -814,21 +840,24 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage // 计算费用 cost := s.calculateRecordUsageCost(ctx, result, apiKey, billingModel, multiplier, imageMultiplier, opts) // response_model:按上游成功响应自报的模型计费(渠道显式开启才生效)。 - // 采纳条件见 responseModelBillingDeclaration + hasIdentifiedResponseModelPricing, - // 且重算成本不得高于基线——上游声明永远不能抬高用户费用。任一条件不满足都静默 - // 回落基线,即开启本模式前的既有行为。 + // 采纳条件见 responseModelBillingDeclaration + hasIdentifiedResponseModelPricing + // + responseModelBillingAdoptable。任一条件不满足都静默回落基线,即开启本模式前的 + // 既有行为。响应模型与基线同名时直接跳过:重算必然同价,白跑一次定价解析。 if responseModel := responseModelBillingDeclaration( input.BillingModelSource, result.UpstreamResponseModel, result.UpstreamResponseModelConflict, - result.ImageCount > 0, - ); responseModel != "" && s.hasIdentifiedResponseModelPricing(ctx, responseModel, apiKey) { - responseCost := s.calculateRecordUsageCost(ctx, result, apiKey, responseModel, multiplier, imageMultiplier, opts) - if cost != nil && responseCost != nil && responseCost.TotalCost <= cost.TotalCost+responseModelBillingCostEpsilon { - // billingModel 到此为止只是定价查表的入参,后续流程只消费 cost, - // 因此这里不改写它,改由日志记录实际生效的计费基准。 - logResponseModelBillingApplied("service.gateway", account, result.RequestID, billingModel, responseModel, cost, responseCost) - cost = responseCost + result.ImageCount > 0 || result.AudioUsage != nil || result.SearchCount > 0, + ); responseModel != "" && !strings.EqualFold(responseModel, strings.TrimSpace(billingModel)) { + if identified, responseChannelPriced := s.hasIdentifiedResponseModelPricing(ctx, responseModel, apiKey); identified { + responseCost := s.calculateRecordUsageCost(ctx, result, apiKey, responseModel, multiplier, imageMultiplier, opts) + baselineChannelPriced := s.resolveChannelPricing(ctx, billingModel, apiKey) != nil + if responseModelBillingAdoptable(cost, responseCost, baselineChannelPriced, responseChannelPriced) { + // billingModel 到此为止只是定价查表的入参,后续流程只消费 cost, + // 因此这里不改写它,改由日志记录实际生效的计费基准。 + logResponseModelBillingApplied("service.gateway", account, result.RequestID, billingModel, responseModel, cost, responseCost) + cost = responseCost + } } } @@ -995,18 +1024,20 @@ func (s *GatewayService) hasResolvableTokenPricing(ctx context.Context, model st return err == nil } -// hasIdentifiedResponseModelPricing 判断上游自报的响应模型是否可以作为计费基准。 +// hasIdentifiedResponseModelPricing 判断上游自报的响应模型是否可以作为计费基准, +// 并回传它是否解析到了渠道级定价(供 responseModelBillingAdoptable 的跨定价源守卫使用, +// 避免为此再解析一次)。 // 与 hasResolvableTokenPricing 的区别是刻意更严:只接受管理员为该模型显式配置的 // 渠道定价,或价格表中能被确定性识别的条目;不接受按子串猜出来的系列兜底价。 // 详见 responseModelBillingDeclaration 的说明。 -func (s *GatewayService) hasIdentifiedResponseModelPricing(ctx context.Context, model string, apiKey *APIKey) bool { +func (s *GatewayService) hasIdentifiedResponseModelPricing(ctx context.Context, model string, apiKey *APIKey) (identified bool, channelPriced bool) { if strings.TrimSpace(model) == "" { - return false + return false, false } if s.resolveChannelPricing(ctx, model, apiKey) != nil { - return true + return true, true } - return s.billingService.HasIdentifiedTokenPricing(model) + return s.billingService.HasIdentifiedTokenPricing(model), false } // resolveChannelPricing 检查指定模型是否存在渠道级别定价。 diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go index 78318a79e3..c2de3c6472 100644 --- a/backend/internal/service/openai_gateway_usage.go +++ b/backend/internal/service/openai_gateway_usage.go @@ -241,26 +241,33 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec cost = &CostBreakdown{BillingMode: string(BillingModeToken)} } // response_model:按上游成功响应自报的模型计费(渠道显式开启才生效)。 - // 采纳条件见 responseModelBillingDeclaration + hasIdentifiedOpenAIResponsePricing, - // 且重算成本不得高于基线——上游声明永远不能抬高用户费用。任一条件不满足都静默 - // 回落基线,即开启本模式前的既有行为。 + // 采纳条件见 responseModelBillingDeclaration + hasIdentifiedOpenAIResponsePricing + // + responseModelBillingAdoptable。任一条件不满足都静默回落基线,即开启本模式前的 + // 既有行为。响应模型与基线同名时直接跳过:重算必然同价,白跑一次定价解析。 + baselineBillingModel := firstUsageBillingModel(billingModels) if responseModel := responseModelBillingDeclaration( input.BillingModelSource, result.UpstreamResponseModel, result.UpstreamResponseModelConflict, - result.ImageCount > 0 || result.VideoCount > 0 || result.WebSearchCalls > 0, - ); responseModel != "" && s.hasIdentifiedOpenAIResponsePricing(ctx, responseModel, apiKey) { - responseModels := usageBillingModelCandidates(responseModel) - responseCost, responseErr := s.calculateOpenAIRecordUsageCost( - ctx, result, apiKey, responseModels, multiplier, imageMultiplier, - videoMultiplier, baseMultiplier, tokens, serviceTier, longContextBillingEnabled, - ) - if responseErr == nil && responseCost != nil && cost != nil && - responseCost.TotalCost <= cost.TotalCost+responseModelBillingCostEpsilon { - logResponseModelBillingApplied("service.openai_gateway", account, result.RequestID, - firstUsageBillingModel(billingModels), responseModel, cost, responseCost) - billingModels = responseModels - cost = responseCost + result.ImageCount > 0 || result.VideoCount > 0 || result.WebSearchCalls > 0 || + result.AudioUsage != nil || result.SearchCount > 0, + ); responseModel != "" && !strings.EqualFold(responseModel, baselineBillingModel) { + if identified, responseChannelPriced := s.hasIdentifiedOpenAIResponsePricing(ctx, responseModel, apiKey); identified { + responseModels := usageBillingModelCandidates(responseModel) + responseCost, responseErr := s.calculateOpenAIRecordUsageCost( + ctx, result, apiKey, responseModels, multiplier, imageMultiplier, + videoMultiplier, baseMultiplier, tokens, serviceTier, longContextBillingEnabled, + ) + // 基线定价源以 baselineBillingModel 为准:它正是 calculateOpenAIRecordUsageCost + // 内部做渠道定价判断时使用的模型,且"首候选有渠道价"必然意味着首候选就是实际 + // 定价基准(有渠道价就一定能算出价,循环不会落到后续候选)。 + baselineChannelPriced := s.resolveOpenAIChannelPricing(ctx, baselineBillingModel, apiKey) != nil + if responseErr == nil && responseModelBillingAdoptable(cost, responseCost, baselineChannelPriced, responseChannelPriced) { + logResponseModelBillingApplied("service.openai_gateway", account, result.RequestID, + baselineBillingModel, responseModel, cost, responseCost) + billingModels = responseModels + cost = responseCost + } } } @@ -456,19 +463,21 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec return nil } -// hasIdentifiedOpenAIResponsePricing 判断上游自报的响应模型是否可以作为计费基准。 +// hasIdentifiedOpenAIResponsePricing 判断上游自报的响应模型是否可以作为计费基准, +// 并回传它是否解析到了渠道级定价(供 responseModelBillingAdoptable 的跨定价源守卫使用, +// 避免为此再解析一次)。 // 只接受管理员为该模型显式配置的渠道定价,或价格表中能被确定性识别的条目; // 刻意不接受按子串猜出来的系列兜底价,否则上游随便编一个含 "haiku" 的名字就能把 // 计费拉到最便宜的系列价上。详见 responseModelBillingDeclaration。 -func (s *OpenAIGatewayService) hasIdentifiedOpenAIResponsePricing(ctx context.Context, model string, apiKey *APIKey) bool { +func (s *OpenAIGatewayService) hasIdentifiedOpenAIResponsePricing(ctx context.Context, model string, apiKey *APIKey) (identified bool, channelPriced bool) { model = strings.TrimSpace(model) if model == "" { - return false + return false, false } if s.resolveOpenAIChannelPricing(ctx, model, apiKey) != nil { - return true + return true, true } - return s.billingService.HasIdentifiedTokenPricing(model) + return s.billingService.HasIdentifiedTokenPricing(model), false } func (s *OpenAIGatewayService) calculateOpenAIRecordUsageCost( diff --git a/backend/internal/service/response_model_billing_test.go b/backend/internal/service/response_model_billing_test.go index 451a48b057..9194a1684e 100644 --- a/backend/internal/service/response_model_billing_test.go +++ b/backend/internal/service/response_model_billing_test.go @@ -452,6 +452,135 @@ func TestOpenAIGatewayServiceRecordUsage_ResponseModelRejectsUnidentifiedFamilyN require.InDelta(t, baselineCost.ActualCost, userRepo.lastAmount, 1e-12) } +// --- 成本准入的三条不变式 --- + +func TestResponseModelBillingAdoptable(t *testing.T) { + t.Parallel() + cost := func(total float64) *CostBreakdown { + return &CostBreakdown{TotalCost: total, ActualCost: total} + } + tests := []struct { + name string + baseline *CostBreakdown + response *CostBreakdown + baselineChannelPriced bool + responseChannelPriced bool + want bool + }{ + // 1. 不得更贵 + {name: "cheaper_adopted", baseline: cost(1), response: cost(0.5), want: true}, + {name: "equal_adopted", baseline: cost(1), response: cost(1), want: true}, + {name: "float_noise_within_epsilon_adopted", baseline: cost(1), response: cost(1 + 1e-13), want: true}, + {name: "pricier_rejected", baseline: cost(1), response: cost(1.0001)}, + + // 2. 不得把一笔本应计费的请求归零(价格表里有显式写 0 的条目,能通过确定性识别) + {name: "zeroing_a_billable_request_rejected", baseline: cost(1), response: cost(0)}, + {name: "negative_cost_rejected_as_zeroing", baseline: cost(1), response: cost(-1)}, + {name: "already_zero_baseline_unaffected", baseline: cost(0), response: cost(0), want: true}, + + // 3. 不得从渠道定价跨到全局价格表(否则渠道加价被带日期的自报模型名绕过) + {name: "channel_priced_baseline_to_global_rejected", baseline: cost(1), response: cost(0.5), baselineChannelPriced: true}, + {name: "channel_priced_on_both_sides_adopted", baseline: cost(1), response: cost(0.5), baselineChannelPriced: true, responseChannelPriced: true, want: true}, + {name: "global_baseline_to_channel_priced_adopted", baseline: cost(1), response: cost(0.5), responseChannelPriced: true, want: true}, + + {name: "nil_baseline_rejected", response: cost(0.5)}, + {name: "nil_response_rejected", baseline: cost(1)}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tt.want, responseModelBillingAdoptable( + tt.baseline, tt.response, tt.baselineChannelPriced, tt.responseChannelPriced, + )) + }) + } +} + +// --- 按次/按量计费请求一律不采纳(门的调用点接线) --- +// +// 搜索附加费是叠加在 token 成本之上的,所以"采纳与否"会体现在最终金额上,本用例因此 +// 能真正区分两条分支。语音(AudioUsage)走的是与模型无关的按量单价,采纳与否金额相同, +// 无法用金额断言区分,故只由 TestResponseModelBillingDeclaration 覆盖门本身。 + +func TestGatewayServiceRecordUsage_ResponseModelSkippedForSearchSurchargedRequest(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + svc := newGatewayRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{}) + tokens := UsageTokens{InputTokens: 100, OutputTokens: 50} + cheaper, pricier, _, pricierCost := orderedResponseBillingModels(t, svc.billingService, tokens, anthropicCheapFixtureModel, anthropicPriceyFixtureModel) + + const searchCalls = 2 + searchCost := svc.billingService.CalculateSearchCost(searchCalls, nil, 1.1) + require.NotNil(t, searchCost) + require.Greater(t, searchCost.ActualCost, 0.0, "夹具附加费必须非零,否则断言分不出两条分支") + + err := svc.RecordUsage(context.Background(), &RecordUsageInput{ + Result: &ForwardResult{ + RequestID: "gateway_response_model_search_surcharge", + Usage: ClaudeUsage{InputTokens: 100, OutputTokens: 50}, + Model: pricier, + UpstreamResponseModel: cheaper, + SearchCount: searchCalls, + Duration: time.Second, + }, + APIKey: &APIKey{ID: 501, Quota: 100}, + User: &User{ID: 601}, + Account: &Account{ID: 701}, + ChannelUsageFields: ChannelUsageFields{ + ChannelID: 9, + OriginalModel: pricier, + ChannelMappedModel: pricier, + BillingModelSource: BillingModelSourceResponse, + }, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + want := pricierCost.ActualCost + searchCost.ActualCost + require.InDelta(t, want, usageRepo.lastLog.ActualCost, 1e-12) + require.InDelta(t, want, userRepo.lastAmount, 1e-12) +} + +func TestOpenAIGatewayServiceRecordUsage_ResponseModelSkippedForSearchSurchargedRequest(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{}, nil) + tokens := UsageTokens{InputTokens: 20, OutputTokens: 10} + cheaper, pricier, _, pricierCost := orderedResponseBillingModels(t, svc.billingService, tokens, openAICheapFixtureModel, openAIPriceyFixtureModel) + + const searchCalls = 3 + searchCost := svc.billingService.CalculateSearchCost(searchCalls, nil, 1.1) + require.NotNil(t, searchCost) + require.Greater(t, searchCost.ActualCost, 0.0, "夹具附加费必须非零,否则断言分不出两条分支") + + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "openai_response_model_search_surcharge", + Model: pricier, + UpstreamModel: pricier, + UpstreamResponseModel: cheaper, + SearchCount: searchCalls, + Usage: OpenAIUsage{InputTokens: 20, OutputTokens: 10}, + Duration: time.Second, + }, + APIKey: &APIKey{ID: 10}, + User: &User{ID: 20}, + Account: &Account{ID: 30}, + ChannelUsageFields: ChannelUsageFields{ + ChannelID: 9, + OriginalModel: pricier, + ChannelMappedModel: pricier, + BillingModelSource: BillingModelSourceResponse, + }, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + want := pricierCost.ActualCost + searchCost.ActualCost + require.InDelta(t, want, usageRepo.lastLog.ActualCost, 1e-12) + require.InDelta(t, want, userRepo.lastAmount, 1e-12) +} + // --- 渠道配置透传 --- func TestToUsageFields_ResponseModelSourcePassesThrough(t *testing.T) {