Merge pull request #5439 from pigzwy/feat/response-model-billing

feat(billing): support safe billing by upstream response model
This commit is contained in:
Wesley Liddick
2026-08-10 19:47:22 +08:00
committed by GitHub
15 changed files with 852 additions and 17 deletions
@@ -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"`
@@ -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) {
// 标准化模型名称(转小写)
+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:
@@ -693,6 +693,80 @@ type recordUsageCoreInput struct {
ChannelUsageFields
}
// responseModelBillingCostEpsilon 吸收两次成本计算之间的浮点末位误差,
// 避免同价模型因浮点误差被判成"更贵"而白白放弃采纳。
const responseModelBillingCostEpsilon = 1e-12
// responseModelBillingDeclaration 返回可用于计费的上游响应模型;返回空字符串表示
// 必须沿用基线计费模型。两条计费主干(Anthropic 系 / OpenAI 系)共用本准入判断。
//
// 渠道把 billing_model_source 设为 response_model,等于把"按哪个模型计价"的一部分
// 决定权交给上游,因此准入条件必须收紧:
// - 只在渠道显式开启该模式时生效,其余模式一律不看响应模型;
// - 一次请求内出现过互相冲突的模型声明时不采纳(无法确定上游究竟服务了哪个模型);
// - 图片 / 视频 / 网页搜索 / 语音 / 搜索附加费这类按次按量计费的请求不采纳:它们按张、
// 按秒、按次定价,与本模式的 token 定价准入检查不是同一套价格表,混用会让一个只验过
// token 价的模型名去决定媒体单价。新增按次计费形态时必须同步扩这个入参。
//
// 调用方还必须额外满足两条:模型能被价格表确定性识别(见
// hasIdentifiedResponseModelPricing / hasIdentifiedOpenAIResponsePricing),以及通过
// responseModelBillingAdoptable 的成本准入。
func responseModelBillingDeclaration(source, responseModel string, conflict, mediaBilled bool) string {
if source != BillingModelSourceResponse || conflict || mediaBilled {
return ""
}
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) {
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,6 +839,27 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage
// 计算费用
cost := s.calculateRecordUsageCost(ctx, result, apiKey, billingModel, multiplier, imageMultiplier, opts)
// response_model:按上游成功响应自报的模型计费(渠道显式开启才生效)。
// 采纳条件见 responseModelBillingDeclaration + hasIdentifiedResponseModelPricing
// + responseModelBillingAdoptable。任一条件不满足都静默回落基线,即开启本模式前的
// 既有行为。响应模型与基线同名时直接跳过:重算必然同价,白跑一次定价解析。
if responseModel := responseModelBillingDeclaration(
input.BillingModelSource,
result.UpstreamResponseModel,
result.UpstreamResponseModelConflict,
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
}
}
}
// 判断计费方式:订阅模式 vs 余额模式
isSubscriptionBilling := subscription != nil && apiKey.Group != nil && apiKey.Group.IsSubscriptionType()
@@ -929,6 +1024,22 @@ func (s *GatewayService) hasResolvableTokenPricing(ctx context.Context, model st
return err == nil
}
// hasIdentifiedResponseModelPricing 判断上游自报的响应模型是否可以作为计费基准,
// 并回传它是否解析到了渠道级定价(供 responseModelBillingAdoptable 的跨定价源守卫使用,
// 避免为此再解析一次)。
// 与 hasResolvableTokenPricing 的区别是刻意更严:只接受管理员为该模型显式配置的
// 渠道定价,或价格表中能被确定性识别的条目;不接受按子串猜出来的系列兜底价。
// 详见 responseModelBillingDeclaration 的说明。
func (s *GatewayService) hasIdentifiedResponseModelPricing(ctx context.Context, model string, apiKey *APIKey) (identified bool, channelPriced bool) {
if strings.TrimSpace(model) == "" {
return false, false
}
if s.resolveChannelPricing(ctx, model, apiKey) != nil {
return true, true
}
return s.billingService.HasIdentifiedTokenPricing(model), false
}
// resolveChannelPricing 检查指定模型是否存在渠道级别定价。
// 返回非 nil 的 ResolvedPricing 表示有渠道定价,nil 表示走默认定价路径。
func (s *GatewayService) resolveChannelPricing(ctx context.Context, billingModel string, apiKey *APIKey) *ResolvedPricing {
@@ -240,6 +240,36 @@ 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:按上游成功响应自报的模型计费(渠道显式开启才生效)。
// 采纳条件见 responseModelBillingDeclaration + hasIdentifiedOpenAIResponsePricing
// + responseModelBillingAdoptable。任一条件不满足都静默回落基线,即开启本模式前的
// 既有行为。响应模型与基线同名时直接跳过:重算必然同价,白跑一次定价解析。
baselineBillingModel := firstUsageBillingModel(billingModels)
if responseModel := responseModelBillingDeclaration(
input.BillingModelSource,
result.UpstreamResponseModel,
result.UpstreamResponseModelConflict,
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
}
}
}
// Determine billing type
isSubscriptionBilling := subscription != nil && apiKey.Group != nil && apiKey.Group.IsSubscriptionType()
@@ -433,6 +463,23 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
return nil
}
// hasIdentifiedOpenAIResponsePricing 判断上游自报的响应模型是否可以作为计费基准,
// 并回传它是否解析到了渠道级定价(供 responseModelBillingAdoptable 的跨定价源守卫使用,
// 避免为此再解析一次)。
// 只接受管理员为该模型显式配置的渠道定价,或价格表中能被确定性识别的条目;
// 刻意不接受按子串猜出来的系列兜底价,否则上游随便编一个含 "haiku" 的名字就能把
// 计费拉到最便宜的系列价上。详见 responseModelBillingDeclaration。
func (s *OpenAIGatewayService) hasIdentifiedOpenAIResponsePricing(ctx context.Context, model string, apiKey *APIKey) (identified bool, channelPriced bool) {
model = strings.TrimSpace(model)
if model == "" {
return false, false
}
if s.resolveOpenAIChannelPricing(ctx, model, apiKey) != nil {
return true, true
}
return s.billingService.HasIdentifiedTokenPricing(model), false
}
func (s *OpenAIGatewayService) calculateOpenAIRecordUsageCost(
ctx context.Context,
result *OpenAIForwardResult,
+44 -10
View File
@@ -652,6 +652,33 @@ func (s *PricingService) GetModelPricing(modelName string) *LiteLLMModelPricing
modelLower := strings.ToLower(strings.TrimSpace(modelName))
lookupCandidates := s.buildModelLookupCandidates(modelLower)
// 1~3. 确定性识别(精确名 / 已知拼写变体 / 去掉日期版本后缀)
if pricing := s.lookupIdentifiedModelPricingLocked(lookupCandidates); pricing != nil {
return pricing
}
// 4. 基于模型系列匹配(Claude)
if pricing := s.matchByModelFamily(lookupCandidates[0]); pricing != nil {
return pricing
}
// 5. OpenAI 模型回退策略
if strings.HasPrefix(lookupCandidates[0], "gpt-") {
return s.matchOpenAIModel(lookupCandidates[0])
}
return nil
}
// lookupIdentifiedModelPricingLocked 只做"确定性识别"的三步查找:精确键、已知拼写
// 变体、去掉日期/版本后缀后的同名条目。它刻意不包含 matchByModelFamily /
// matchOpenAIModel 这类按子串猜系列的兜底——那些兜底会给任意名字都返回一个价格。
// 调用方必须持有 s.mu 读锁。
func (s *PricingService) lookupIdentifiedModelPricingLocked(lookupCandidates []string) *LiteLLMModelPricing {
if len(lookupCandidates) == 0 {
return nil
}
// 1. 精确匹配
for _, candidate := range lookupCandidates {
if candidate == "" {
@@ -681,19 +708,26 @@ func (s *PricingService) GetModelPricing(modelName string) *LiteLLMModelPricing
}
}
// 4. 基于模型系列匹配(Claude)
if pricing := s.matchByModelFamily(lookupCandidates[0]); pricing != nil {
return pricing
}
// 5. OpenAI 模型回退策略
if strings.HasPrefix(lookupCandidates[0], "gpt-") {
return s.matchOpenAIModel(lookupCandidates[0])
}
return nil
}
// GetIdentifiedModelPricing 在价格表中确定性地识别模型,识别不到时返回 nil。
// 与 GetModelPricing 的区别:不会退化成按 "opus"/"haiku" 之类子串猜出的系列兜底价。
// 用于必须区分"这是价格表里已知的模型"和"这只是名字里带某个关键词"的场景。
func (s *PricingService) GetIdentifiedModelPricing(modelName string) *LiteLLMModelPricing {
if s == nil {
return nil
}
s.mu.RLock()
defer s.mu.RUnlock()
modelLower := strings.ToLower(strings.TrimSpace(modelName))
if modelLower == "" {
return nil
}
return s.lookupIdentifiedModelPricingLocked(s.buildModelLookupCandidates(modelLower))
}
func (s *PricingService) buildModelLookupCandidates(modelLower string) []string {
rawCandidates := []string{
modelLower,
@@ -0,0 +1,596 @@
//go:build unit
package service
import (
"context"
"testing"
"time"
"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) {
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)
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
}
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, anthropicCheapFixtureModel, anthropicPriceyFixtureModel)
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, anthropicCheapFixtureModel, anthropicPriceyFixtureModel)
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, anthropicCheapFixtureModel, anthropicPriceyFixtureModel)
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, openAICheapFixtureModel, openAIPriceyFixtureModel)
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, openAICheapFixtureModel, openAIPriceyFixtureModel)
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, openAICheapFixtureModel, openAIPriceyFixtureModel)
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 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 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) {
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)
}
@@ -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
+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 ──