diff --git a/backend/internal/handler/grok_media.go b/backend/internal/handler/grok_media.go index 7f38fc10b..0522d689b 100644 --- a/backend/internal/handler/grok_media.go +++ b/backend/internal/handler/grok_media.go @@ -472,8 +472,11 @@ func recordGrokMediaUsage( inboundEndpoint := GetInboundEndpoint(c) upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform) quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey) + // OriginalModel 记录客户端请求的模型:composite 分组下 body 已被改写为具体模型, + // 公开别名需从 context 取回,与其他端点的用量归因口径一致(计费不受影响: + // BillingModelSource 为空不会触发来源覆盖)。 channelUsageFields := service.ChannelUsageFields{ - OriginalModel: requestModel, + OriginalModel: clientRequestedModel(c, requestModel), ChannelMappedModel: requestModel, } h.submitOpenAIUsageRecordTask(c.Request.Context(), result, func(ctx context.Context) { diff --git a/backend/internal/service/gateway_usage_billing.go b/backend/internal/service/gateway_usage_billing.go index 1aa156f2e..1606f0f3c 100644 --- a/backend/internal/service/gateway_usage_billing.go +++ b/backend/internal/service/gateway_usage_billing.go @@ -683,13 +683,24 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage multiplier, imageMultiplier := computePeakAwareMultipliers(apiKey, multiplier, timezone.Now()) // 确定计费模型 - billingModel := forwardResultBillingModel(result.Model, result.UpstreamModel) + concreteBillingModel := forwardResultBillingModel(result.Model, result.UpstreamModel) + billingModel := concreteBillingModel if input.BillingModelSource == BillingModelSourceChannelMapped && input.ChannelMappedModel != "" { billingModel = input.ChannelMappedModel } if input.BillingModelSource == BillingModelSourceRequested && input.OriginalModel != "" { billingModel = input.OriginalModel } + // composite 分组的公开别名(如 all/claude)会经 OriginalModel/ChannelMappedModel + // 进入上面的来源覆盖:任意别名查无价会静默落 $0,含家族词的别名则被价格表的 + // 家族模糊匹配错计(如 Opus 流量按 Sonnet 兜底价)。除非管理员为别名显式配置了 + // 渠道定价(OpenRouter 式自定价),composite 请求一律按实际转发的具体模型计费。 + if apiKey.Group != nil && apiKey.Group.Platform == PlatformComposite { + billingModel = s.compositeBillableModel(ctx, apiKey, billingModel, concreteBillingModel) + } + // 通用兜底(与 OpenAI 路径的 usageBillingModelCandidates 语义对齐): + // 选定模型查不到任何价格时回退到实际转发的具体模型。已定价流量不受影响。 + billingModel = s.billableModelWithFallback(ctx, apiKey, billingModel, result.UpstreamModel, result.Model) // 确定 RequestedModel(渠道映射前的原始模型) requestedModel := result.Model @@ -790,6 +801,56 @@ func (s *GatewayService) calculateRecordUsageCost( return s.calculateTokenCost(ctx, result, apiKey, billingModel, multiplier, opts) } +// compositeBillableModel 决定 composite 分组请求的计费模型:来源覆盖把计费模型 +// 换成公开别名等非具体模型时,只有管理员为该名字显式配置了渠道定价才按其计费 +// (OpenRouter 式自定价),否则回退到实际转发的具体模型,避免别名落入价格表的 +// 家族模糊匹配(错价)或查无价($0)。未发生来源覆盖时原样返回。 +func (s *GatewayService) compositeBillableModel(ctx context.Context, apiKey *APIKey, billingModel, concreteBillingModel string) string { + if concreteBillingModel == "" || billingModel == concreteBillingModel { + return billingModel + } + if s.resolveChannelPricing(ctx, billingModel, apiKey) != nil { + return billingModel + } + logger.LegacyPrintf("service.gateway", "[Billing] composite billing model %q has no explicit channel pricing, billing by concrete model %q", billingModel, concreteBillingModel) + return concreteBillingModel +} + +// billableModelWithFallback 在选定计费模型(可能是 composite 公开别名或未定价的映射名) +// 查不到任何价格(渠道价与全局价均无)时,按序回退到实际转发的具体模型,避免静默 $0 计费。 +// 所有候选都无价时保持原值,走既有的 warn + 零成本路径。 +func (s *GatewayService) billableModelWithFallback(ctx context.Context, apiKey *APIKey, billingModel string, fallbacks ...string) string { + if s.hasResolvableTokenPricing(ctx, billingModel, apiKey) { + return billingModel + } + for _, fallback := range fallbacks { + fallback = strings.TrimSpace(fallback) + if fallback == "" || fallback == billingModel { + continue + } + if s.hasResolvableTokenPricing(ctx, fallback, apiKey) { + logger.LegacyPrintf("service.gateway", "[Billing] billing model %q has no pricing, falling back to concrete model %q", billingModel, fallback) + return fallback + } + } + return billingModel +} + +// hasResolvableTokenPricing 判断模型是否能在渠道定价或全局价格表中解析出 token 价格。 +func (s *GatewayService) hasResolvableTokenPricing(ctx context.Context, model string, apiKey *APIKey) bool { + if strings.TrimSpace(model) == "" { + return false + } + if s.resolveChannelPricing(ctx, model, apiKey) != nil { + return true + } + if s.billingService == nil { + return false + } + _, err := s.billingService.GetModelPricing(model) + return err == nil +} + // resolveChannelPricing 检查指定模型是否存在渠道级别定价。 // 返回非 nil 的 ResolvedPricing 表示有渠道定价,nil 表示走默认定价路径。 func (s *GatewayService) resolveChannelPricing(ctx context.Context, billingModel string, apiKey *APIKey) *ResolvedPricing { diff --git a/backend/internal/service/gateway_usage_billing_fallback_test.go b/backend/internal/service/gateway_usage_billing_fallback_test.go new file mode 100644 index 000000000..ecf321859 --- /dev/null +++ b/backend/internal/service/gateway_usage_billing_fallback_test.go @@ -0,0 +1,77 @@ +//go:build unit + +package service + +import ( + "context" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + + "github.com/stretchr/testify/require" +) + +// composite 分组的公开别名经 BillingModelSource 来源覆盖成为计费模型后有两类错计: +// 任意别名(如 team/best)查无价静默落 $0;含家族词的别名(如 all/claude)被价格表 +// 家族模糊匹配错计(Opus 流量按 Sonnet 兜底价)。compositeBillableModel 要求别名必须 +// 有显式渠道定价才可参与计费,否则回退实际转发的具体模型。 +func TestCompositeBillableModel(t *testing.T) { + svc := &GatewayService{billingService: NewBillingService(&config.Config{}, nil)} + apiKey := &APIKey{} + ctx := context.Background() + + // 别名无渠道定价(含家族词也一样)→ 回退具体模型 + require.Equal(t, "claude-opus-4-7", + svc.compositeBillableModel(ctx, apiKey, "all/claude", "claude-opus-4-7")) + require.Equal(t, "claude-sonnet-4", + svc.compositeBillableModel(ctx, apiKey, "team/best", "claude-sonnet-4")) + + // 未发生来源覆盖(计费模型已是具体模型)→ 原样返回 + require.Equal(t, "claude-sonnet-4", + svc.compositeBillableModel(ctx, apiKey, "claude-sonnet-4", "claude-sonnet-4")) + + // 具体模型缺失 → 保持原值(走后续通用兜底/既有路径) + require.Equal(t, "all/claude", + svc.compositeBillableModel(ctx, apiKey, "all/claude", "")) +} + +// billableModelWithFallback 是通用安全网:选定计费模型查不到任何价格时回退到 +// 实际转发的具体模型;已定价流量(含家族兜底可解析的名字)不受影响。 +func TestBillableModelWithFallback(t *testing.T) { + svc := &GatewayService{billingService: NewBillingService(&config.Config{}, nil)} + apiKey := &APIKey{} + ctx := context.Background() + + // 完全无价的别名 → 回退到具体转发模型(claude-sonnet-4 有内置回退价格) + require.Equal(t, "claude-sonnet-4", + svc.billableModelWithFallback(ctx, apiKey, "team/best", "", "claude-sonnet-4")) + + // 已定价模型不回退,候选被忽略 + require.Equal(t, "claude-sonnet-4", + svc.billableModelWithFallback(ctx, apiKey, "claude-sonnet-4", "claude-opus-4")) + + // 所有候选都无价 → 保持原值,走既有 warn + 零成本路径 + require.Equal(t, "team/best", + svc.billableModelWithFallback(ctx, apiKey, "team/best", "another/alias", "")) + + // 空计费模型 + 有价候选 → 取候选 + require.Equal(t, "claude-sonnet-4", + svc.billableModelWithFallback(ctx, apiKey, "", "claude-sonnet-4")) +} + +func TestHasResolvableTokenPricing(t *testing.T) { + svc := &GatewayService{billingService: NewBillingService(&config.Config{}, nil)} + apiKey := &APIKey{} + ctx := context.Background() + + require.True(t, svc.hasResolvableTokenPricing(ctx, "claude-sonnet-4", apiKey)) + // 注意:含家族词的名字(all/claude)会被价格表家族兜底解析为"有价", + // 这正是 compositeBillableModel 必须先于通用兜底拦截别名的原因。 + require.True(t, svc.hasResolvableTokenPricing(ctx, "all/claude", apiKey)) + require.False(t, svc.hasResolvableTokenPricing(ctx, "team/best", apiKey)) + require.False(t, svc.hasResolvableTokenPricing(ctx, "", apiKey)) + + // billingService 缺失时 fail-closed(不误判有价) + empty := &GatewayService{} + require.False(t, empty.hasResolvableTokenPricing(ctx, "claude-sonnet-4", apiKey)) +}