diff --git a/backend/internal/service/channel_plaza.go b/backend/internal/service/channel_plaza.go index a71a6e4f1..1e4dee271 100644 --- a/backend/internal/service/channel_plaza.go +++ b/backend/internal/service/channel_plaza.go @@ -28,7 +28,8 @@ type PlazaModel struct { // PlazaGroup 模型广场中以分组为顶层的条目。 // // 与 AvailableGroupRef 相比多了 Description 与 Models;Models 来自该分组关联渠道的 -// 支持模型(按分组平台隔离,防跨平台泄漏),与「可用渠道」页口径一致。 +// 支持模型(普通分组按分组平台隔离,Composite 分组展开关联渠道已配置的 +// 具体平台),与「可用渠道」页口径一致。 type PlazaGroup struct { ID int64 Name string @@ -99,8 +100,12 @@ func (s *ChannelService) ListPlazaGroups(ctx context.Context) ([]PlazaGroup, err order = append(order, g.ID) } - // modelIdx[groupID][modelName] = index into byGroup[groupID].Models - modelIdx := make(map[int64]map[string]int, len(groups)) + type modelKey struct { + platform string + name string + } + // modelIdx[groupID][platform+modelName] = index into byGroup[groupID].Models + modelIdx := make(map[int64]map[modelKey]int, len(groups)) for i := range channels { ch := &channels[i] if ch.Status != StatusActive { @@ -117,23 +122,28 @@ func (s *ChannelService) ListPlazaGroups(ctx context.Context) ([]PlazaGroup, err } idx := modelIdx[gid] if idx == nil { - idx = make(map[string]int, len(supported)) + idx = make(map[modelKey]int, len(supported)) modelIdx[gid] = idx } for j := range supported { m := supported[j] - if m.Platform != pg.Platform { + if pg.Platform == PlatformComposite { + if !isConcreteRequestPlatform(m.Platform) { + continue + } + } else if m.Platform != pg.Platform { continue } pricing := plazaImageDisplayPricing(m.Pricing, groupEnt[gid]) - if at, seen := idx[m.Name]; seen { + key := modelKey{platform: m.Platform, name: m.Name} + if at, seen := idx[key]; seen { // 先见者胜;仅当已存条目无定价而新条目有定价时升级。 if pg.Models[at].Pricing == nil && pricing != nil { pg.Models[at].Pricing = pricing } continue } - idx[m.Name] = len(pg.Models) + idx[key] = len(pg.Models) pg.Models = append(pg.Models, PlazaModel{ Name: m.Name, Platform: m.Platform, @@ -150,7 +160,12 @@ func (s *ChannelService) ListPlazaGroups(ctx context.Context) ([]PlazaGroup, err if len(pg.Models) == 0 { continue } - sort.SliceStable(pg.Models, func(i, j int) bool { return pg.Models[i].Name < pg.Models[j].Name }) + sort.SliceStable(pg.Models, func(i, j int) bool { + if pg.Models[i].Name != pg.Models[j].Name { + return pg.Models[i].Name < pg.Models[j].Name + } + return pg.Models[i].Platform < pg.Models[j].Platform + }) for j := range pg.Models { pg.Models[j].OfficialPricing = s.lookupOfficialPricing(pg.Models[j].Name, officialMemo) } diff --git a/backend/internal/service/channel_plaza_test.go b/backend/internal/service/channel_plaza_test.go index 82d426ce9..554654149 100644 --- a/backend/internal/service/channel_plaza_test.go +++ b/backend/internal/service/channel_plaza_test.go @@ -107,6 +107,68 @@ func TestListPlazaGroups_PlatformIsolation(t *testing.T) { require.Equal(t, "gpt-5", byName["g-gpt"][0].Name) } +func TestListPlazaGroups_CompositeIncludesConfiguredConcretePlatforms(t *testing.T) { + anthropicPrice := 3e-6 + openAIPrice := 2e-6 + ch := Channel{ + ID: 1, Name: "multi", Status: StatusActive, GroupIDs: []int64{10}, + ModelPricing: []ChannelModelPricing{ + {Platform: PlatformAnthropic, Models: []string{"shared-model"}, InputPrice: &anthropicPrice}, + {Platform: PlatformOpenAI, Models: []string{"shared-model"}, InputPrice: &openAIPrice}, + {Platform: "", Models: []string{"empty-platform"}}, + {Platform: PlatformComposite, Models: []string{"nested-composite"}}, + {Platform: "unknown-platform", Models: []string{"unknown-platform"}}, + }, + } + groups := []Group{{ID: 10, Name: "composite", Platform: PlatformComposite, RateMultiplier: 1}} + + out, err := newPlazaChannelService([]Channel{ch}, groups, nil).ListPlazaGroups(context.Background()) + + require.NoError(t, err) + require.Len(t, out, 1) + require.Len(t, out[0].Models, 2, "only concrete platforms are included and same-named models remain distinct") + require.Equal(t, PlatformAnthropic, out[0].Models[0].Platform) + require.Equal(t, PlatformOpenAI, out[0].Models[1].Platform) + require.InDelta(t, anthropicPrice, *out[0].Models[0].Pricing.InputPrice, 1e-12) + require.InDelta(t, openAIPrice, *out[0].Models[1].Pricing.InputPrice, 1e-12) +} + +func TestListPlazaGroups_CompositeAndOrdinaryGroupsDoNotLeakPlatforms(t *testing.T) { + ch := Channel{ + ID: 1, Name: "multi", Status: StatusActive, GroupIDs: []int64{10, 20}, + ModelPricing: []ChannelModelPricing{ + {Platform: PlatformAnthropic, Models: []string{"claude-sonnet"}, InputPrice: testPtrFloat64(3e-6)}, + {Platform: PlatformOpenAI, Models: []string{"gpt-5"}, InputPrice: testPtrFloat64(2e-6)}, + }, + } + groups := []Group{ + {ID: 10, Name: "anthropic-only", Platform: PlatformAnthropic, RateMultiplier: 1}, + {ID: 20, Name: "composite", Platform: PlatformComposite, RateMultiplier: 1}, + } + + out, err := newPlazaChannelService([]Channel{ch}, groups, nil).ListPlazaGroups(context.Background()) + + require.NoError(t, err) + require.Len(t, out, 2) + byName := map[string]PlazaGroup{} + for _, group := range out { + byName[group.Name] = group + } + require.Len(t, byName["anthropic-only"].Models, 1) + require.Equal(t, []PlazaModel{{ + Name: "claude-sonnet", Platform: PlatformAnthropic, Pricing: byName["anthropic-only"].Models[0].Pricing, + }}, byName["anthropic-only"].Models) + require.Len(t, byName["composite"].Models, 2) + require.Equal(t, []string{"claude-sonnet", "gpt-5"}, []string{ + byName["composite"].Models[0].Name, + byName["composite"].Models[1].Name, + }) + require.Equal(t, []string{PlatformAnthropic, PlatformOpenAI}, []string{ + byName["composite"].Models[0].Platform, + byName["composite"].Models[1].Platform, + }) +} + func TestListPlazaGroups_InactiveChannelSkipped(t *testing.T) { inactive := plazaPricedChannel(1, "off", []int64{10}, "anthropic", "claude-sonnet") inactive.Status = "inactive" diff --git a/frontend/src/components/modelPlaza/PlazaModelPricingTable.vue b/frontend/src/components/modelPlaza/PlazaModelPricingTable.vue index 77f76e883..edc72365f 100644 --- a/frontend/src/components/modelPlaza/PlazaModelPricingTable.vue +++ b/frontend/src/components/modelPlaza/PlazaModelPricingTable.vue @@ -59,13 +59,22 @@
{{ m.name }} + + {{ platformLabel(m.platform) }} + { // 旧 bug:image_output_price × 0.1 = 0.000003 被当按次价 expect(text).not.toContain('$0.000003') }) + + it('Composite 分组中相同模型名按具体平台分别展示徽章', () => { + const anthropic = tokenModel({ name: 'shared-model', platform: 'anthropic' }) + const openai = tokenModel({ name: 'shared-model', platform: 'openai' }) + const wrapper = mount(PlazaModelPricingTable, { + props: { + models: [anthropic, openai], + platform: 'composite', + rateMultiplier: 1 + } + }) + + const rows = wrapper.findAll('tbody tr') + expect(rows).toHaveLength(2) + expect(rows.map((row) => row.find('td').text())).toEqual([ + 'shared-modelAnthropic', + 'shared-modelOpenAI' + ]) + expect(wrapper.text()).toContain('Anthropic') + expect(wrapper.text()).toContain('OpenAI') + }) })