feat(billing): support safe upstream response model billing
This commit is contained in:
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"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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: '搜索分组...',
|
||||
|
||||
@@ -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 ──
|
||||
|
||||
Reference in new issue
Block a user