diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 46e996c5..e326770b 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -174,7 +174,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { claudeTokenProvider := service.ProvideClaudeTokenProvider(accountRepository, geminiTokenCache, oAuthService, oAuthRefreshAPI) digestSessionStore := service.NewDigestSessionStore() channelRepository := repository.NewChannelRepository(db) - channelService := service.NewChannelService(channelRepository, groupRepository, apiKeyAuthCacheInvalidator) + channelService := service.NewChannelService(channelRepository, groupRepository, apiKeyAuthCacheInvalidator, pricingService) modelPricingResolver := service.NewModelPricingResolver(channelService, billingService) balanceNotifyService := service.ProvideBalanceNotifyService(emailService, settingRepository, accountRepository) gatewayService := service.NewGatewayService(accountRepository, groupRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, identityService, httpUpstream, deferredService, claudeTokenProvider, sessionLimitCache, rpmCache, digestSessionStore, settingService, tlsFingerprintProfileService, channelService, modelPricingResolver, balanceNotifyService) diff --git a/backend/internal/service/channel.go b/backend/internal/service/channel.go index fa1a87c1..158bf8a3 100644 --- a/backend/internal/service/channel.go +++ b/backend/internal/service/channel.go @@ -451,19 +451,29 @@ func buildPricingIndex(pricings []ChannelModelPricing) map[string]*platformPrici // SupportedModels 计算渠道的支持模型列表,结果保证不含通配符。 // -// 算法(以渠道自身的 ModelMapping 为唯一入口): -// - 遍历 Channel.ModelMapping 的每个 platform 条目; -// - 映射 key 不带尾部 "*":直接作为一个支持模型名(即使没有匹配的定价行,也会产出 Pricing=nil 的条目); -// - 映射 key 带尾部 "*":用同 platform 的 ModelPricing.Models 做前缀匹配展开(定价中带 "*" 的条目被忽略,因为它们本身就是模式,不是具体模型名); -// - 映射 key 为 `"*"`(单独一个星号)将展开为该平台所有定价模型(前缀为空 → 全匹配)。这是刻意行为,用于"将该平台所有模型透传"的场景; -// - 未在 ModelMapping 中出现的 platform 不会产出任何条目——这是**刻意设计**("没配映射就不显示"),即使该平台有定价行。 +// 算法(mapping ∪ pricing 并联): // -// 当映射 key(exact 或 wildcard 展开后的候选)能命中定价时,结果中的 Name 使用**定价的原始大小写** -// (定价是模型身份的事实来源),否则保留映射 key 的原始大小写。 -// 每个结果尝试从 platform 索引查找精确定价,未配置则 Pricing=nil。 -// 结果按 (Platform, Name) 稳定排序,并按 (Platform, lowercase(Name)) 去重。 +// - Pass A(mapping):遍历 ModelMapping +// - 精确 src → target:显示名 = src(用户视角),定价用 target 在同 platform 定价里查 +// (mapping 改写后实际计费的是 target;这是用户感知的"实际花费")。 +// target 为空或为通配符时退化为按 src 自查。 +// - 通配符 src(如 "claude-3-*"):用同 platform 定价里前缀匹配的模型作为候选展开, +// 每个候选用自身定价(通配符场景一般是 passthrough,target 通常也是通配符)。 +// - "*" 单独 mapping key 走通配符分支(前缀为空 → 全展开)。 +// - Pass B(pricing-only):遍历 ModelPricing 中所有非通配符模型,对未在 Pass A 添加过的 +// 补齐——显示名 = 定价模型名,定价 = 自身(这是关键修复:定价存在即代表渠道支持该模型, +// 即使没配映射)。 +// +// 显示名命中定价时使用**定价的原始大小写**(定价是模型身份的事实来源)。 +// 按 (Platform, Name) 稳定排序,按 (Platform, lowercase(Name)) 去重,先到者胜出。 +// +// 注意:定价仅在 channel.ModelPricing 内查找——全局 LiteLLM 回落由调用方 +// (`ChannelService.ListAvailable`)在合成展示数据时叠加。 func (c *Channel) SupportedModels() []SupportedModel { - if c == nil || len(c.ModelMapping) == 0 { + if c == nil { + return nil + } + if len(c.ModelMapping) == 0 && len(c.ModelPricing) == 0 { return nil } @@ -476,21 +486,24 @@ func (c *Channel) SupportedModels() []SupportedModel { seen := make(map[dedupKey]struct{}) result := make([]SupportedModel, 0) - add := func(platform, name string, pidx *platformPricingIndex) { - key := dedupKey{platform: platform, name: strings.ToLower(name)} + // lookup 在 platform pricing index 中按精确名查定价,命中时返回定价大小写。 + lookup := func(pidx *platformPricingIndex, name string) (display string, pricing *ChannelModelPricing) { + if pidx == nil || name == "" { + return name, nil + } + lower := strings.ToLower(name) + if p, ok := pidx.byLower[lower]; ok { + return pidx.originalCase[lower], p + } + return name, nil + } + + add := func(platform, displayName string, pricing *ChannelModelPricing) { + key := dedupKey{platform: platform, name: strings.ToLower(displayName)} if _, ok := seen[key]; ok { return } seen[key] = struct{}{} - var pricing *ChannelModelPricing - displayName := name - if pidx != nil { - lower := strings.ToLower(name) - if p, ok := pidx.byLower[lower]; ok { - pricing = p - displayName = pidx.originalCase[lower] // 定价大小写胜出 - } - } result = append(result, SupportedModel{ Name: displayName, Platform: platform, @@ -498,12 +511,13 @@ func (c *Channel) SupportedModels() []SupportedModel { }) } + // Pass A:从 mapping 展开 for platform, mapping := range c.ModelMapping { if len(mapping) == 0 { continue } - pidx := idx[platform] // 可能为 nil(该平台无定价行) - for src := range mapping { + pidx := idx[platform] + for src, target := range mapping { prefix, isWild := splitWildcardSuffix(src) if isWild { if pidx == nil { @@ -512,12 +526,32 @@ func (c *Channel) SupportedModels() []SupportedModel { prefixLower := strings.ToLower(prefix) for _, candidate := range pidx.names { if strings.HasPrefix(strings.ToLower(candidate), prefixLower) { - add(platform, candidate, pidx) + display, pricing := lookup(pidx, candidate) + add(platform, display, pricing) } } continue } - add(platform, src, pidx) + // 精确 mapping:定价按 target 查;target 缺失/通配则退化按 src 查 + pricingKey := target + if pricingKey == "" { + pricingKey = src + } + if _, targetWild := splitWildcardSuffix(pricingKey); targetWild { + pricingKey = src + } + _, pricing := lookup(pidx, pricingKey) + // 显示名优先用 src 在定价里的原始大小写(若 src 本身是个定价模型名) + displayName, _ := lookup(pidx, src) + add(platform, displayName, pricing) + } + } + + // Pass B:从 pricing 补齐 mapping 未覆盖的具体模型(修复"定价存在但没配映射 → 不显示") + for platform, pidx := range idx { + for _, name := range pidx.names { + display, pricing := lookup(pidx, name) + add(platform, display, pricing) } } diff --git a/backend/internal/service/channel_available.go b/backend/internal/service/channel_available.go index 49f711ab..815730e3 100644 --- a/backend/internal/service/channel_available.go +++ b/backend/internal/service/channel_available.go @@ -36,7 +36,10 @@ type AvailableChannel struct { // ListAvailable 返回所有渠道的可用视图:每个渠道附带关联分组信息与支持模型列表。 // -// 支持模型通过 (*Channel).SupportedModels() 计算得到(见 channel.go)。 +// 支持模型通过 (*Channel).SupportedModels() 计算(mapping ∪ pricing 并联)。 +// 对于渠道未配置定价的模型,进一步用 PricingService 的全局 LiteLLM 数据合成 +// 一份展示用定价,让用户看到默认价格而非"未配置"。 +// // 关联分组信息通过 groupRepo.ListActive 查询后按 ID 映射;渠道 GroupIDs 中未在活跃列表中 // 的分组(已停用或删除)会被忽略。 // @@ -78,6 +81,9 @@ func (s *ChannelService) ListAvailable(ctx context.Context) ([]AvailableChannel, ch.normalizeBillingModelSource() + supported := ch.SupportedModels() + s.fillGlobalPricingFallback(supported) + out = append(out, AvailableChannel{ ID: ch.ID, Name: ch.Name, @@ -86,7 +92,7 @@ func (s *ChannelService) ListAvailable(ctx context.Context) ([]AvailableChannel, BillingModelSource: ch.BillingModelSource, RestrictModels: ch.RestrictModels, Groups: groups, - SupportedModels: ch.SupportedModels(), + SupportedModels: supported, }) } @@ -95,3 +101,49 @@ func (s *ChannelService) ListAvailable(ctx context.Context) ([]AvailableChannel, }) return out, nil } + +// fillGlobalPricingFallback 对未命中渠道定价的支持模型,从全局 LiteLLM 数据合成一份 +// 展示用定价(按 token 计费)。仅用于「可用渠道」展示,不影响真实计费链路。 +// +// 当 s.pricingService 为 nil(测试场景),跳过回落。 +func (s *ChannelService) fillGlobalPricingFallback(models []SupportedModel) { + if s.pricingService == nil { + return + } + for i := range models { + if models[i].Pricing != nil { + continue + } + lp := s.pricingService.GetModelPricing(models[i].Name) + if lp == nil { + continue + } + models[i].Pricing = synthesizePricingFromLiteLLM(lp) + } +} + +// synthesizePricingFromLiteLLM 把 LiteLLM 的定价数据转成 ChannelModelPricing 形态, +// 仅用于展示。BillingMode 固定为 token;图片场景的 OutputCostPerImageToken 也归到 +// ImageOutputPrice 字段(与渠道侧"图片输出按 token 计价"语义一致)。 +// +// LiteLLM 中字段 0 视为未配置,不带入展示。 +func synthesizePricingFromLiteLLM(lp *LiteLLMModelPricing) *ChannelModelPricing { + if lp == nil { + return nil + } + return &ChannelModelPricing{ + BillingMode: BillingModeToken, + InputPrice: nonZeroPtr(lp.InputCostPerToken), + OutputPrice: nonZeroPtr(lp.OutputCostPerToken), + CacheWritePrice: nonZeroPtr(lp.CacheCreationInputTokenCost), + CacheReadPrice: nonZeroPtr(lp.CacheReadInputTokenCost), + ImageOutputPrice: nonZeroPtr(lp.OutputCostPerImageToken), + } +} + +func nonZeroPtr(v float64) *float64 { + if v == 0 { + return nil + } + return &v +} diff --git a/backend/internal/service/channel_available_test.go b/backend/internal/service/channel_available_test.go index 86bb4bb6..8be70ceb 100644 --- a/backend/internal/service/channel_available_test.go +++ b/backend/internal/service/channel_available_test.go @@ -75,7 +75,7 @@ func newAvailableChannelService(channels []Channel, groupRepo GroupRepository) * repo := &mockChannelRepository{ listAllFn: func(ctx context.Context) ([]Channel, error) { return channels, nil }, } - return NewChannelService(repo, groupRepo, nil) + return NewChannelService(repo, groupRepo, nil, nil) } func TestListAvailable_EmptyActiveGroups_NoGroupsAttached(t *testing.T) { @@ -134,7 +134,7 @@ func TestListAvailable_ListAllErrorPropagates(t *testing.T) { listAllFn: func(ctx context.Context) ([]Channel, error) { return nil, sentinel }, } groupRepo := &stubGroupRepoForAvailable{} - svc := NewChannelService(repo, groupRepo, nil) + svc := NewChannelService(repo, groupRepo, nil, nil) out, err := svc.ListAvailable(context.Background()) require.Nil(t, out) require.ErrorIs(t, err, sentinel) diff --git a/backend/internal/service/channel_service.go b/backend/internal/service/channel_service.go index 51984400..4e08df4a 100644 --- a/backend/internal/service/channel_service.go +++ b/backend/internal/service/channel_service.go @@ -143,17 +143,21 @@ type ChannelService struct { repo ChannelRepository groupRepo GroupRepository authCacheInvalidator APIKeyAuthCacheInvalidator + pricingService *PricingService // 用于「可用渠道」展示时回落到全局定价;可为 nil(测试场景) cache atomic.Value // *channelCache cacheSF singleflight.Group } -// NewChannelService 创建渠道服务实例 -func NewChannelService(repo ChannelRepository, groupRepo GroupRepository, authCacheInvalidator APIKeyAuthCacheInvalidator) *ChannelService { +// NewChannelService 创建渠道服务实例。 +// pricingService 仅供 ListAvailable 在渠道未配置定价时回落到全局 LiteLLM 数据; +// 计费热路径走独立的 ModelPricingResolver,与此参数无关。可传 nil。 +func NewChannelService(repo ChannelRepository, groupRepo GroupRepository, authCacheInvalidator APIKeyAuthCacheInvalidator, pricingService *PricingService) *ChannelService { s := &ChannelService{ repo: repo, groupRepo: groupRepo, authCacheInvalidator: authCacheInvalidator, + pricingService: pricingService, } return s } diff --git a/backend/internal/service/channel_service_test.go b/backend/internal/service/channel_service_test.go index e44b882b..e737a211 100644 --- a/backend/internal/service/channel_service_test.go +++ b/backend/internal/service/channel_service_test.go @@ -189,11 +189,11 @@ func (m *mockChannelAuthCacheInvalidator) InvalidateAuthCacheByGroupID(_ context // --------------------------------------------------------------------------- func newTestChannelService(repo *mockChannelRepository) *ChannelService { - return NewChannelService(repo, nil, nil) + return NewChannelService(repo, nil, nil, nil) } func newTestChannelServiceWithAuth(repo *mockChannelRepository, auth *mockChannelAuthCacheInvalidator) *ChannelService { - return NewChannelService(repo, nil, auth) + return NewChannelService(repo, nil, auth, nil) } // makeStandardRepo returns a repo that serves one active channel with anthropic pricing diff --git a/backend/internal/service/channel_test.go b/backend/internal/service/channel_test.go index 7cb1b272..164861fb 100644 --- a/backend/internal/service/channel_test.go +++ b/backend/internal/service/channel_test.go @@ -476,29 +476,12 @@ func TestSupportedModels_WildcardExpandedFromPricing(t *testing.T) { for _, m := range got { names = append(names, m.Name) } - require.ElementsMatch(t, []string{"claude-sonnet-4-5", "claude-sonnet-4-6"}, names) + require.ElementsMatch(t, []string{"claude-sonnet-4-5", "claude-sonnet-4-6", "claude-opus-4-6"}, names) for _, m := range got { require.NotContains(t, m.Name, "*") } } -func TestSupportedModels_PlatformWithoutMappingSkipped(t *testing.T) { - ch := &Channel{ - ModelPricing: []ChannelModelPricing{ - {ID: 1, Platform: "anthropic", Models: []string{"claude-sonnet-4-6"}}, - {ID: 2, Platform: "openai", Models: []string{"gpt-4o"}}, - }, - ModelMapping: map[string]map[string]string{ - "anthropic": {"claude-sonnet-4-6": "claude-sonnet-4-6"}, - // openai 没有 mapping 条目 - }, - } - - got := ch.SupportedModels() - require.Len(t, got, 1) - require.Equal(t, "anthropic", got[0].Platform) - require.Equal(t, "claude-sonnet-4-6", got[0].Name) -} func TestSupportedModels_MissingPricingKeepsNilPricing(t *testing.T) { ch := &Channel{ @@ -584,7 +567,8 @@ func TestSupportedModels_WildcardOnlyPricingRowsSkipped(t *testing.T) { } func TestSupportedModels_WildcardPrefixMatchesNothing(t *testing.T) { - // 通配符模式无任何对应定价模型时,该平台应产出 0 个模型。 + // 通配符模式无任何对应定价模型时,该平台 mapping 路不产出; + // 但其他平台的 pricing-only 模型仍会通过 Pass B 出现。 ch := &Channel{ ModelPricing: []ChannelModelPricing{ {ID: 1, Platform: "openai", Models: []string{"gpt-4o"}}, @@ -593,11 +577,15 @@ func TestSupportedModels_WildcardPrefixMatchesNothing(t *testing.T) { "anthropic": {"gpt-foo-*": "gpt-foo-1"}, }, } - require.Empty(t, ch.SupportedModels()) + got := ch.SupportedModels() + require.Len(t, got, 1) + require.Equal(t, "openai", got[0].Platform) + require.Equal(t, "gpt-4o", got[0].Name) } func TestSupportedModels_CrossPlatformPricingDoesNotBleed(t *testing.T) { - // anthropic 的通配符不应拉入 openai 定价行,哪怕名字恰好前缀匹配。 + // anthropic 的通配符不应把 openai 定价行拉到 anthropic 平台下; + // openai 的 pricing-only 模型则正常通过 Pass B 暴露在 openai 平台下。 ch := &Channel{ ModelPricing: []ChannelModelPricing{ {ID: 1, Platform: "openai", Models: []string{"claude-sonnet-4-6"}}, @@ -606,7 +594,10 @@ func TestSupportedModels_CrossPlatformPricingDoesNotBleed(t *testing.T) { "anthropic": {"claude-sonnet-*": "x"}, }, } - require.Empty(t, ch.SupportedModels()) + got := ch.SupportedModels() + require.Len(t, got, 1) + require.Equal(t, "openai", got[0].Platform, "不能把 openai 定价标记为 anthropic 模型") + require.Equal(t, "claude-sonnet-4-6", got[0].Name) } func TestSupportedModels_CaseInsensitiveDedup(t *testing.T) { @@ -626,7 +617,8 @@ func TestSupportedModels_CaseInsensitiveDedup(t *testing.T) { } func TestSupportedModels_EmptyPlatformMapping(t *testing.T) { - // ModelMapping 有一个 platform key 但 value 是空 map —— 该 platform 应被跳过。 + // ModelMapping 平台 key 存在但 value 为空 map:mapping 路跳过该平台, + // 但 pricing 路仍会把该平台的定价模型补齐(关键修复:azcc 这种"只配定价不配映射"渠道)。 ch := &Channel{ ModelPricing: []ChannelModelPricing{ {ID: 1, Platform: "anthropic", Models: []string{"claude-sonnet-4-6"}}, @@ -635,7 +627,11 @@ func TestSupportedModels_EmptyPlatformMapping(t *testing.T) { "anthropic": {}, }, } - require.Empty(t, ch.SupportedModels()) + got := ch.SupportedModels() + require.Len(t, got, 1) + require.Equal(t, "anthropic", got[0].Platform) + require.Equal(t, "claude-sonnet-4-6", got[0].Name) + require.NotNil(t, got[0].Pricing) } func TestSupportedModels_ExactKeyUsesPricedCaseWhenAvailable(t *testing.T) { @@ -668,3 +664,65 @@ func TestSupportedModels_AsteriskOnlyMappingExpandsAllPriced(t *testing.T) { names := []string{got[0].Name, got[1].Name} require.ElementsMatch(t, []string{"gpt-4o", "gpt-4o-mini"}, names) } + +func TestSupportedModels_PricingOnlyNoMapping(t *testing.T) { + // 渠道完全没配 mapping,只配了定价 —— 应该把所有定价模型作为支持模型返回。 + // 这是修复前的核心 bug 场景(前端显示"未配置模型")。 + ch := &Channel{ + ModelPricing: []ChannelModelPricing{ + {ID: 1, Platform: "anthropic", Models: []string{"claude-opus-4-6"}, InputPrice: testPtrFloat64(1.5e-5)}, + {ID: 2, Platform: "anthropic", Models: []string{"claude-haiku-4-5"}, InputPrice: testPtrFloat64(3e-7)}, + }, + } + got := ch.SupportedModels() + require.Len(t, got, 2) + require.Equal(t, "claude-haiku-4-5", got[0].Name) + require.NotNil(t, got[0].Pricing) + require.Equal(t, int64(2), got[0].Pricing.ID) + require.Equal(t, "claude-opus-4-6", got[1].Name) + require.Equal(t, int64(1), got[1].Pricing.ID) +} + +func TestSupportedModels_ExactMappingUsesTargetPricing(t *testing.T) { + // 精确 mapping `src → target`:定价应按 target 查(实际计费的是 target), + // 而不是按 src 自查。 + ch := &Channel{ + ModelPricing: []ChannelModelPricing{ + {ID: 100, Platform: "anthropic", Models: []string{"req-model"}, InputPrice: testPtrFloat64(3e-6)}, + {ID: 200, Platform: "anthropic", Models: []string{"served-model"}, InputPrice: testPtrFloat64(1.5e-5)}, + }, + ModelMapping: map[string]map[string]string{ + "anthropic": { + "req-model": "served-model", + }, + }, + } + got := ch.SupportedModels() + require.Len(t, got, 2) + require.Equal(t, "req-model", got[0].Name) + require.NotNil(t, got[0].Pricing) + require.Equal(t, int64(200), got[0].Pricing.ID, "req-model 显示但定价是 served-model 的(mapping target)") + require.Equal(t, "served-model", got[1].Name) + require.Equal(t, int64(200), got[1].Pricing.ID) +} + +func TestSupportedModels_ExactMappingTargetMissingFromPricing(t *testing.T) { + // `src → target` 但 target 不在渠道定价里 —— 结果中 src 的 Pricing 为 nil + // (等待 ListAvailable 阶段的全局 LiteLLM 回落填充)。 + ch := &Channel{ + ModelPricing: []ChannelModelPricing{ + {ID: 1, Platform: "anthropic", Models: []string{"some-priced-model"}, InputPrice: testPtrFloat64(1.5e-5)}, + }, + ModelMapping: map[string]map[string]string{ + "anthropic": { + "missing-src": "missing-target", + }, + }, + } + got := ch.SupportedModels() + require.Len(t, got, 2) + require.Equal(t, "missing-src", got[0].Name) + require.Nil(t, got[0].Pricing, "target 在渠道定价中缺失时不虚假填充,留给 ListAvailable 走 LiteLLM 回落") + require.Equal(t, "some-priced-model", got[1].Name) + require.NotNil(t, got[1].Pricing) +} diff --git a/backend/internal/service/model_pricing_resolver_test.go b/backend/internal/service/model_pricing_resolver_test.go index 7484eed5..4548c1d5 100644 --- a/backend/internal/service/model_pricing_resolver_test.go +++ b/backend/internal/service/model_pricing_resolver_test.go @@ -184,7 +184,7 @@ func newResolverWithChannel(t *testing.T, pricing []ChannelModelPricing) *ModelP return map[int64]string{groupID: "anthropic"}, nil }, } - cs := NewChannelService(repo, nil, nil) + cs := NewChannelService(repo, nil, nil, nil) bs := newTestBillingServiceForResolver() return NewModelPricingResolver(cs, bs) } @@ -517,7 +517,7 @@ func TestResolve_WithChannelOverride_CacheError(t *testing.T) { return nil, errors.New("database unavailable") }, } - cs := NewChannelService(repo, nil, nil) + cs := NewChannelService(repo, nil, nil, nil) bs := newTestBillingServiceForResolver() r := NewModelPricingResolver(cs, bs)