mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 12:57:57 +08:00
Merge pull request #5267 from wucm667/fix/issue-5264-model-plaza-composite
fix(model-plaza): show Composite group models
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -59,13 +59,22 @@
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="m in sortedModels"
|
||||
:key="m.name"
|
||||
:key="`${m.platform}:${m.name}`"
|
||||
class="border-b border-gray-100 transition-colors last:border-b-0 hover:bg-gray-50/70 dark:border-dark-800 dark:hover:bg-dark-800/50"
|
||||
>
|
||||
<!-- 模型名 + 非 token 计费模式徽章 -->
|
||||
<td class="border-r border-gray-100 py-2.5 pl-5 pr-4 align-middle dark:border-dark-700/60">
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ m.name }}</span>
|
||||
<span
|
||||
v-if="platform && m.platform !== platform"
|
||||
:class="[
|
||||
'inline-flex items-center rounded-md px-1.5 py-0.5 text-[10px] font-medium',
|
||||
platformBadgeLightClass(m.platform)
|
||||
]"
|
||||
>
|
||||
{{ platformLabel(m.platform) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="billingMode(m) !== BILLING_MODE_TOKEN"
|
||||
class="rounded-md bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-500 dark:bg-dark-700/70 dark:text-dark-300"
|
||||
@@ -203,7 +212,7 @@
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { formatScaled } from '@/utils/pricing'
|
||||
import { platformAccentColor } from '@/utils/platformColors'
|
||||
import { platformAccentColor, platformBadgeLightClass, platformLabel } from '@/utils/platformColors'
|
||||
import {
|
||||
BILLING_MODE_TOKEN,
|
||||
BILLING_MODE_IMAGE,
|
||||
|
||||
@@ -383,4 +383,25 @@ describe('PlazaModelPricingTable', () => {
|
||||
// 旧 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')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user