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 ──