mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 13:18:18 +08:00
Merge pull request #5439 from pigzwy/feat/response-model-billing
feat(billing): support safe billing by upstream response model
This commit is contained in:
@@ -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) {
|
||||
// 标准化模型名称(转小写)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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