mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
fix(billing): 渠道定价对带后缀的模型名不再被官方兜底价覆盖
修复带后缀模型无法命中渠道自定义定价的问题。
This commit is contained in:
@@ -85,7 +85,7 @@ func (r *ModelPricingResolver) Resolve(ctx context.Context, input PricingInput)
|
||||
|
||||
var chPricing *ChannelModelPricing
|
||||
if input.GroupID != nil && r.channelService != nil {
|
||||
chPricing = r.channelService.GetChannelModelPricing(ctx, *input.GroupID, input.Model)
|
||||
chPricing = r.lookupChannelPricingNormalized(ctx, *input.GroupID, input.Model)
|
||||
if chPricing != nil {
|
||||
mode := chPricing.BillingMode
|
||||
if mode == "" {
|
||||
@@ -177,9 +177,33 @@ func (r *ModelPricingResolver) resolveBasePricing(model string) (*ModelPricing,
|
||||
return pricing, PricingSourceLiteLLM
|
||||
}
|
||||
|
||||
// lookupChannelPricingNormalized 查找渠道定价:先用字面模型名做精确/通配匹配,
|
||||
// 未命中时用与官方兜底价一致的归一化模型名再查一次。
|
||||
//
|
||||
// 官方兜底价对 OpenAI/Codex 族会把 gpt-5.6-luna-high 这类变体名归一化到基名
|
||||
// (billing_service.go 的 normalizeKnownOpenAICodexModel 分支),而渠道定价此前
|
||||
// 只认字面名。两者不对称导致:管理员只配基名、请求模型带 effort 后缀时,渠道定价
|
||||
// 未命中而官方兜底命中,计费候选循环首个成功即返回,渠道定价永远轮不到(issue #5256)。
|
||||
//
|
||||
// 字面名优先,保证管理员对具体变体的显式配价不被基名覆盖;非 OpenAI 模型
|
||||
// normalizeKnownOpenAICodexModel 返回空串,此处天然 no-op。
|
||||
func (r *ModelPricingResolver) lookupChannelPricingNormalized(ctx context.Context, groupID int64, model string) *ChannelModelPricing {
|
||||
if r.channelService == nil {
|
||||
return nil
|
||||
}
|
||||
if pricing := r.channelService.GetChannelModelPricing(ctx, groupID, model); pricing != nil {
|
||||
return pricing
|
||||
}
|
||||
normalized := normalizeKnownOpenAICodexModel(model)
|
||||
if normalized == "" || strings.EqualFold(normalized, strings.TrimSpace(model)) {
|
||||
return nil
|
||||
}
|
||||
return r.channelService.GetChannelModelPricing(ctx, groupID, normalized)
|
||||
}
|
||||
|
||||
// applyChannelOverrides 应用渠道定价覆盖
|
||||
func (r *ModelPricingResolver) applyChannelOverrides(ctx context.Context, groupID int64, model string, resolved *ResolvedPricing) {
|
||||
chPricing := r.channelService.GetChannelModelPricing(ctx, groupID, model)
|
||||
chPricing := r.lookupChannelPricingNormalized(ctx, groupID, model)
|
||||
if chPricing == nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
// issue #5256 回归测试:使用记录的费用统计没有按照渠道定价的价格进行计算。
|
||||
//
|
||||
// 场景:管理员在渠道定价把 gpt-5.6-luna 的输入价从官方 $0.2/M 调成 $0.4/M。
|
||||
// 当请求模型带 effort 后缀(gpt-5.6-luna-high)而渠道只配了基名时,渠道定价查找
|
||||
// 用字面名未命中,官方兜底价却会把后缀名归一化到 gpt-5.6-luna 并命中静态价
|
||||
// (pricing_service.go 的 gpt-5.6-luna 前缀分支),计费候选循环首个成功即返回
|
||||
// → 落库的是官方 0.2 而不是渠道 0.4。
|
||||
//
|
||||
// 测试走与生产一致的 populateChannelCache → OpenAIGatewayService.RecordUsage 路径,
|
||||
// 断言落库 UsageLog 的 InputCost。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
// 1M 输入 token 下,渠道价与官方兜底价的期望费用(USD)
|
||||
channelPricingExpectedChannelCost = 0.4
|
||||
channelPricingExpectedOfficialCost = 0.2
|
||||
// 用于验证「不相关的渠道配置不会被误命中」的对照价
|
||||
channelPricingUnrelatedCost = 0.9
|
||||
)
|
||||
|
||||
// tokenPricingForModels 构造 token 计费模式的渠道定价;inputPerMillion 单位为 USD/1M token。
|
||||
func tokenPricingForModels(models []string, inputPerMillion float64) ChannelModelPricing {
|
||||
return ChannelModelPricing{
|
||||
Platform: PlatformOpenAI,
|
||||
Models: models,
|
||||
BillingMode: BillingModeToken,
|
||||
InputPrice: float64Ptr(inputPerMillion / 1e6),
|
||||
OutputPrice: float64Ptr(2.4e-6),
|
||||
CacheWritePrice: float64Ptr(0.5e-6),
|
||||
CacheReadPrice: float64Ptr(0.04e-6),
|
||||
}
|
||||
}
|
||||
|
||||
func newChannelServiceWithPricings(groupID int64, pricings []ChannelModelPricing) *ChannelService {
|
||||
ch := Channel{
|
||||
ID: 1,
|
||||
Name: "codex-channel",
|
||||
Status: StatusActive,
|
||||
ModelPricing: pricings,
|
||||
GroupIDs: []int64{groupID},
|
||||
}
|
||||
cs := &ChannelService{}
|
||||
cs.cache.Store(populateChannelCache([]Channel{ch}, map[int64]string{groupID: PlatformOpenAI}))
|
||||
return cs
|
||||
}
|
||||
|
||||
// recordUsageWithChannelPricing 用给定的渠道定价跑一次 RecordUsage,返回落库的 UsageLog。
|
||||
func recordUsageWithChannelPricing(t *testing.T, requestedModel string, subscriptionGroup bool, pricings []ChannelModelPricing) *UsageLog {
|
||||
t.Helper()
|
||||
const groupID = int64(777)
|
||||
|
||||
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
|
||||
svc := newOpenAIRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil)
|
||||
cs := newChannelServiceWithPricings(groupID, pricings)
|
||||
svc.channelService = cs
|
||||
svc.resolver = NewModelPricingResolver(cs, svc.billingService)
|
||||
|
||||
group := &Group{
|
||||
ID: groupID,
|
||||
Platform: PlatformOpenAI,
|
||||
RateMultiplier: 1,
|
||||
}
|
||||
if subscriptionGroup {
|
||||
group.SubscriptionType = SubscriptionTypeSubscription
|
||||
}
|
||||
|
||||
err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
|
||||
Result: &OpenAIForwardResult{
|
||||
RequestID: "resp_luna_5256",
|
||||
Model: requestedModel,
|
||||
BillingModel: requestedModel,
|
||||
Usage: OpenAIUsage{
|
||||
InputTokens: 1_000_000,
|
||||
OutputTokens: 0,
|
||||
},
|
||||
Duration: time.Second,
|
||||
},
|
||||
ChannelUsageFields: ChannelUsageFields{
|
||||
OriginalModel: requestedModel,
|
||||
ChannelMappedModel: requestedModel,
|
||||
},
|
||||
APIKey: &APIKey{
|
||||
ID: 1,
|
||||
GroupID: i64p(groupID),
|
||||
Group: group,
|
||||
},
|
||||
User: &User{ID: 1},
|
||||
Account: &Account{ID: 1, Platform: PlatformOpenAI},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, usageRepo.lastLog)
|
||||
return usageRepo.lastLog
|
||||
}
|
||||
|
||||
// 基线:请求模型与渠道定价 key 完全一致 → 按渠道价计。
|
||||
func TestChannelPricing_ExactModelMatch(t *testing.T) {
|
||||
log := recordUsageWithChannelPricing(t, "gpt-5.6-luna", false, []ChannelModelPricing{
|
||||
tokenPricingForModels([]string{"gpt-5.6-luna"}, channelPricingExpectedChannelCost),
|
||||
})
|
||||
require.InDelta(t, channelPricingExpectedChannelCost, log.InputCost, 1e-9)
|
||||
}
|
||||
|
||||
// issue #5256 主回归:请求模型带 effort 后缀、渠道只配基名(无通配符)→ 仍应按渠道价计。
|
||||
// 修复前此处得到 0.2(官方兜底价)。
|
||||
func TestChannelPricing_SuffixedModelUsesNormalizedChannelPricing(t *testing.T) {
|
||||
log := recordUsageWithChannelPricing(t, "gpt-5.6-luna-high", false, []ChannelModelPricing{
|
||||
tokenPricingForModels([]string{"gpt-5.6-luna"}, channelPricingExpectedChannelCost),
|
||||
})
|
||||
require.InDelta(t, channelPricingExpectedChannelCost, log.InputCost, 1e-9,
|
||||
"suffixed request model should fall back to the normalized channel pricing; got %v (%v = official fallback)",
|
||||
log.InputCost, channelPricingExpectedOfficialCost)
|
||||
}
|
||||
|
||||
// 同一根因的另一种变体名:上游返回带日期后缀的模型名
|
||||
// (isCodexDateSuffix,如 gpt-5.6-luna-2026-08-01),渠道只配基名 → 仍应按渠道价计。
|
||||
func TestChannelPricing_DateSuffixedModelUsesNormalizedChannelPricing(t *testing.T) {
|
||||
log := recordUsageWithChannelPricing(t, "gpt-5.6-luna-2026-08-01", false, []ChannelModelPricing{
|
||||
tokenPricingForModels([]string{"gpt-5.6-luna"}, channelPricingExpectedChannelCost),
|
||||
})
|
||||
require.InDelta(t, channelPricingExpectedChannelCost, log.InputCost, 1e-9,
|
||||
"date-suffixed request model should fall back to the normalized channel pricing; got %v", log.InputCost)
|
||||
}
|
||||
|
||||
// 精确匹配优先:同时配了变体名与基名时,请求变体名必须命中变体的显式配价,
|
||||
// 不能被归一化后的基名覆盖。
|
||||
func TestChannelPricing_ExactVariantWinsOverNormalizedBaseName(t *testing.T) {
|
||||
log := recordUsageWithChannelPricing(t, "gpt-5.6-luna-high", false, []ChannelModelPricing{
|
||||
tokenPricingForModels([]string{"gpt-5.6-luna-high"}, channelPricingUnrelatedCost),
|
||||
tokenPricingForModels([]string{"gpt-5.6-luna"}, channelPricingExpectedChannelCost),
|
||||
})
|
||||
require.InDelta(t, channelPricingUnrelatedCost, log.InputCost, 1e-9,
|
||||
"explicit per-variant channel pricing must win over the normalized base name")
|
||||
}
|
||||
|
||||
// 订阅型分组走同一条渠道定价解析路径。
|
||||
func TestChannelPricing_SuffixedModelSubscriptionGroup(t *testing.T) {
|
||||
log := recordUsageWithChannelPricing(t, "gpt-5.6-luna-high", true, []ChannelModelPricing{
|
||||
tokenPricingForModels([]string{"gpt-5.6-luna"}, channelPricingExpectedChannelCost),
|
||||
})
|
||||
require.InDelta(t, channelPricingExpectedChannelCost, log.InputCost, 1e-9)
|
||||
}
|
||||
|
||||
// 反向保护:渠道只配了不相关的模型时,归一化查找不得误命中该配置,
|
||||
// 应落回官方兜底价。
|
||||
func TestChannelPricing_UnrelatedChannelModelNotMatched(t *testing.T) {
|
||||
log := recordUsageWithChannelPricing(t, "gpt-5.6-luna-high", false, []ChannelModelPricing{
|
||||
tokenPricingForModels([]string{"gpt-5.4"}, channelPricingUnrelatedCost),
|
||||
})
|
||||
require.InDelta(t, channelPricingExpectedOfficialCost, log.InputCost, 1e-9,
|
||||
"normalized lookup must not match an unrelated channel pricing entry")
|
||||
}
|
||||
Reference in New Issue
Block a user