fix(billing): bill composite alias requests by the concrete forwarded model

Composite public aliases (e.g. all/claude) reach the Anthropic/Gemini
billing core via OriginalModel/ChannelMappedModel source overrides.
Unknown aliases resolved to no pricing and silently recorded $0 cost,
while family-word aliases were mispriced by the fallback family match
(Opus traffic billed at the Sonnet fallback rate). The OpenAI path
already guards this via usageBillingModelCandidates; the shared
recordUsageCore had neither the guard nor a fallback.

- composite groups: unless the admin explicitly configured channel
  pricing for the alias (OpenRouter-style custom pricing), bill by the
  concrete forwarded model
- general safety net: when the selected billing model has no resolvable
  pricing at all, fall back to the concrete forwarded model instead of
  silently recording $0
- grok media usage records now attribute OriginalModel to the client
  requested public alias, consistent with every other endpoint
  (billing unaffected: empty BillingModelSource never triggers source
  overrides)

Priced traffic and non-composite groups are unaffected.
This commit is contained in:
shaw
2026-07-23 10:26:58 +08:00
parent 90c4f50a5e
commit ba88cc239c
3 changed files with 143 additions and 2 deletions
+4 -1
View File
@@ -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) {
@@ -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 {
@@ -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))
}