feat(billing): support safe upstream response model billing

This commit is contained in:
pigzwy authored and shaw committed 2026-08-10 18:45:14 +08:00
1 parent 10a4c6e3ad
commit 9096492b55
12 files changed
+392 -5

No files matched your search

@@ -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"`
+5 -1
View File
@@ -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)
+1 -1
View File
@@ -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 根据映射结果和上游实际模型构建映射链描述。
@@ -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")
@@ -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:
@@ -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()
@@ -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,
@@ -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)
}
+2
View File
@@ -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
@@ -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...',
@@ -143,6 +143,7 @@ export default {
billingModelSourceChannelMapped: '以渠道映射后的模型计费',
billingModelSourceRequested: '以请求模型计费',
billingModelSourceUpstream: '以最终模型计费',
billingModelSourceResponse: '按上游响应模型计费',
billingModelSourceHint: '控制使用哪个模型名称进行定价查找',
selectedCount: '已选 {count} 个',
searchGroups: '搜索分组...',
+2 -1
View File
@@ -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 ──