From 377d1230fc6dfbc81e4c4a5049befdf790aba0f4 Mon Sep 17 00:00:00 2001
From: feeeei
Date: Sun, 23 Aug 2026 13:41:33 +0800
Subject: [PATCH] =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E5=B9=BF=E5=9C=BA=EF=BC=9A?=
=?UTF-8?q?=E6=8C=89=E8=AE=A1=E8=B4=B9=E9=98=B6=E6=A2=AF=E5=8D=95=E4=BB=B7?=
=?UTF-8?q?=E8=A1=A8=E5=B1=95=E7=A4=BA=E9=95=BF=E4=B8=8A=E4=B8=8B=E6=96=87?=
=?UTF-8?q?=E6=A1=A3=E4=BD=8D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新建 ModelPlazaService(持计费服务与定价解析器)承接广场聚合,
token 模型的单价与档位全部取自 ResolveContextPricingSchedule,
渠道选择与计费同源;图片/按次模型沿用原档位合成
- 官方参考价改走计费目录(LiteLLM → 内置兜底 → 模型策略),带官方阶梯
- DTO 增加 long_context_pricing_enabled / long_context_basis /
official_pricing.intervals
- 前端实付与官方三列按档分行(标签只在首列,其余列按行对齐),
缓存列按档展示写/读价,边际计价以徽章与 tooltip 标注,
分组关闭阶梯时在头部说明
---
backend/cmd/server/wire_gen.go | 3 +-
.../handler/available_channel_handler.go | 23 +-
.../internal/handler/model_plaza_handler.go | 63 +--
.../handler/model_plaza_handler_test.go | 56 ++-
backend/internal/service/channel_available.go | 10 +-
.../service/channel_available_test.go | 6 +-
backend/internal/service/channel_plaza.go | 256 -------------
.../internal/service/model_plaza_service.go | 361 ++++++++++++++++++
...za_test.go => model_plaza_service_test.go} | 197 ++++++++--
backend/internal/service/wire.go | 1 +
frontend/src/api/modelPlaza.ts | 18 +-
.../modelPlaza/PlazaGroupSection.vue | 19 +
.../modelPlaza/PlazaModelPricingTable.vue | 120 +++++-
.../__tests__/PlazaGroupSection.spec.ts | 115 ++++++
.../__tests__/PlazaModelPricingTable.spec.ts | 130 +++++++
frontend/src/i18n/locales/en/dashboard.ts | 8 +-
frontend/src/i18n/locales/zh/dashboard.ts | 8 +-
17 files changed, 1045 insertions(+), 349 deletions(-)
delete mode 100644 backend/internal/service/channel_plaza.go
create mode 100644 backend/internal/service/model_plaza_service.go
rename backend/internal/service/{channel_plaza_test.go => model_plaza_service_test.go} (56%)
create mode 100644 frontend/src/components/modelPlaza/__tests__/PlazaGroupSection.spec.ts
diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go
index 1300989dc6..a51e13bb9d 100644
--- a/backend/cmd/server/wire_gen.go
+++ b/backend/cmd/server/wire_gen.go
@@ -304,7 +304,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
handlerPaymentHandler := handler.NewPaymentHandler(paymentService, paymentConfigService)
paymentWebhookHandler := handler.NewPaymentWebhookHandler(paymentService, registry)
availableChannelHandler := handler.NewAvailableChannelHandler(channelService, apiKeyService, settingService)
- modelPlazaHandler := handler.NewModelPlazaHandler(channelService, apiKeyService, settingService)
+ modelPlazaService := service.NewModelPlazaService(channelRepository, groupRepository, pricingService, billingService, modelPricingResolver)
+ modelPlazaHandler := handler.NewModelPlazaHandler(modelPlazaService, apiKeyService, settingService)
imageTaskStore := repository.NewImageTaskStore(redisClient)
imageTaskService := service.ProvideImageTaskService(imageTaskStore, imageStorageSettingService)
asyncImageHandler := handler.NewAsyncImageHandler(imageTaskService, openAIGatewayHandler)
diff --git a/backend/internal/handler/available_channel_handler.go b/backend/internal/handler/available_channel_handler.go
index 300eb1b1a7..a6b7a36a3c 100644
--- a/backend/internal/handler/available_channel_handler.go
+++ b/backend/internal/handler/available_channel_handler.go
@@ -284,13 +284,13 @@ func toUserSupportedModels(
return out
}
-// toUserPricing 将 service 层定价转换为用户 DTO;入参为 nil 时返回 nil。
-func toUserPricing(p *service.ChannelModelPricing) *userSupportedModelPricing {
- if p == nil {
+// toUserPricingIntervals 将定价区间转换为用户 DTO 白名单形态;nil 入参返回 nil(JSON omitempty 可省略)。
+func toUserPricingIntervals(src []service.PricingInterval) []userPricingIntervalDTO {
+ if src == nil {
return nil
}
- intervals := make([]userPricingIntervalDTO, 0, len(p.Intervals))
- for _, iv := range p.Intervals {
+ intervals := make([]userPricingIntervalDTO, 0, len(src))
+ for _, iv := range src {
intervals = append(intervals, userPricingIntervalDTO{
MinTokens: iv.MinTokens,
MaxTokens: iv.MaxTokens,
@@ -302,6 +302,19 @@ func toUserPricing(p *service.ChannelModelPricing) *userSupportedModelPricing {
PerRequestPrice: iv.PerRequestPrice,
})
}
+ return intervals
+}
+
+// toUserPricing 将 service 层定价转换为用户 DTO;入参为 nil 时返回 nil。
+func toUserPricing(p *service.ChannelModelPricing) *userSupportedModelPricing {
+ if p == nil {
+ return nil
+ }
+ intervals := toUserPricingIntervals(p.Intervals)
+ if intervals == nil {
+ // 用户侧定价的 intervals 固定输出数组(空配置为 []),保持既有契约。
+ intervals = []userPricingIntervalDTO{}
+ }
billingMode := string(p.BillingMode)
if billingMode == "" {
billingMode = string(service.BillingModeToken)
diff --git a/backend/internal/handler/model_plaza_handler.go b/backend/internal/handler/model_plaza_handler.go
index 0bf294d443..4ea24a6a20 100644
--- a/backend/internal/handler/model_plaza_handler.go
+++ b/backend/internal/handler/model_plaza_handler.go
@@ -17,39 +17,43 @@ import (
// - 匿名:仅非专属分组(订阅型照常展示);
// - 登录:非专属分组 + user_allowed_groups 授权的专属分组(不检查订阅有效性)。
type ModelPlazaHandler struct {
- channelService *service.ChannelService
+ plazaService *service.ModelPlazaService
apiKeyService *service.APIKeyService
settingService *service.SettingService
}
// NewModelPlazaHandler 创建模型广场 handler。
func NewModelPlazaHandler(
- channelService *service.ChannelService,
+ plazaService *service.ModelPlazaService,
apiKeyService *service.APIKeyService,
settingService *service.SettingService,
) *ModelPlazaHandler {
return &ModelPlazaHandler{
- channelService: channelService,
+ plazaService: plazaService,
apiKeyService: apiKeyService,
settingService: settingService,
}
}
-// modelPlazaOfficialPricing LiteLLM 官方参考价(USD per token)。
+// modelPlazaOfficialPricing 官方参考价(USD per token,与计费目录同源)。
type modelPlazaOfficialPricing struct {
InputPrice *float64 `json:"input_price"`
OutputPrice *float64 `json:"output_price"`
CacheWritePrice *float64 `json:"cache_write_price"`
CacheWrite1hPrice *float64 `json:"cache_write_1h_price,omitempty"`
CacheReadPrice *float64 `json:"cache_read_price"`
+ // Intervals 官方长上下文阶梯,仅多档模型给出。
+ Intervals []userPricingIntervalDTO `json:"intervals,omitempty"`
}
-// modelPlazaModel 广场模型条目:渠道定价(白名单形态)+ 官方参考价。
+// modelPlazaModel 广场模型条目:实收口径展示定价(白名单形态)+ 官方参考价。
type modelPlazaModel struct {
Name string `json:"name"`
Platform string `json:"platform"`
Pricing *userSupportedModelPricing `json:"pricing"`
OfficialPricing *modelPlazaOfficialPricing `json:"official_pricing"`
+ // LongContextBasis 多档时的计价基准:"whole_request"(整单按档)| "marginal"(仅超出部分)。
+ LongContextBasis string `json:"long_context_basis,omitempty"`
}
// modelPlazaGroup 广场分组条目(白名单字段)。
@@ -68,9 +72,11 @@ type modelPlazaGroup struct {
IsExclusive bool `json:"is_exclusive"`
// 生图独立倍率:为 true 时图片计费模型的实付倍率取 ImageRateMultiplier,
// 不取分组/用户专属倍率。
- ImageRateIndependent bool `json:"image_rate_independent"`
- ImageRateMultiplier float64 `json:"image_rate_multiplier"`
- Models []modelPlazaModel `json:"models"`
+ ImageRateIndependent bool `json:"image_rate_independent"`
+ ImageRateMultiplier float64 `json:"image_rate_multiplier"`
+ // 分组是否启用长上下文阶梯计费;关闭时模型实付列只展示最低档/基础价。
+ LongContextPricingEnabled bool `json:"long_context_pricing_enabled"`
+ Models []modelPlazaModel `json:"models"`
}
// modelPlazaResponse 广场页响应。
@@ -98,7 +104,7 @@ func (h *ModelPlazaHandler) Get(c *gin.Context) {
return
}
- groups, err := h.channelService.ListPlazaGroups(c.Request.Context())
+ groups, err := h.plazaService.ListGroups(c.Request.Context())
if err != nil {
response.ErrorFrom(c, err)
return
@@ -161,27 +167,29 @@ func toModelPlazaGroupDTO(g *service.PlazaGroup, userRates map[int64]float64) mo
for i := range g.Models {
m := &g.Models[i]
models = append(models, modelPlazaModel{
- Name: m.Name,
- Platform: m.Platform,
- Pricing: toUserPricing(m.Pricing),
- OfficialPricing: toModelPlazaOfficialPricing(m.OfficialPricing),
+ Name: m.Name,
+ Platform: m.Platform,
+ Pricing: toUserPricing(m.Pricing),
+ OfficialPricing: toModelPlazaOfficialPricing(m.OfficialPricing),
+ LongContextBasis: string(m.LongContextBasis),
})
}
dto := modelPlazaGroup{
- ID: g.ID,
- Name: g.Name,
- Description: g.Description,
- Platform: g.Platform,
- SubscriptionType: g.SubscriptionType,
- RateMultiplier: g.RateMultiplier,
- PeakRateEnabled: g.PeakRateEnabled,
- PeakStart: g.PeakStart,
- PeakEnd: g.PeakEnd,
- PeakRateMultiplier: g.PeakRateMultiplier,
- IsExclusive: g.IsExclusive,
- ImageRateIndependent: g.ImageRateIndependent,
- ImageRateMultiplier: g.ImageRateMultiplier,
- Models: models,
+ ID: g.ID,
+ Name: g.Name,
+ Description: g.Description,
+ Platform: g.Platform,
+ SubscriptionType: g.SubscriptionType,
+ RateMultiplier: g.RateMultiplier,
+ PeakRateEnabled: g.PeakRateEnabled,
+ PeakStart: g.PeakStart,
+ PeakEnd: g.PeakEnd,
+ PeakRateMultiplier: g.PeakRateMultiplier,
+ IsExclusive: g.IsExclusive,
+ ImageRateIndependent: g.ImageRateIndependent,
+ ImageRateMultiplier: g.ImageRateMultiplier,
+ LongContextPricingEnabled: g.LongContextPricingEnabled,
+ Models: models,
}
if rate, ok := userRates[g.ID]; ok {
dto.UserRateMultiplier = &rate
@@ -200,5 +208,6 @@ func toModelPlazaOfficialPricing(p *service.PlazaOfficialPricing) *modelPlazaOff
CacheWritePrice: p.CacheWritePrice,
CacheWrite1hPrice: p.CacheWrite1hPrice,
CacheReadPrice: p.CacheReadPrice,
+ Intervals: toUserPricingIntervals(p.Intervals),
}
}
diff --git a/backend/internal/handler/model_plaza_handler_test.go b/backend/internal/handler/model_plaza_handler_test.go
index a7fc291ab8..97fee63b74 100644
--- a/backend/internal/handler/model_plaza_handler_test.go
+++ b/backend/internal/handler/model_plaza_handler_test.go
@@ -91,7 +91,7 @@ func TestToModelPlazaGroupDTO_UserRateAndFieldWhitelist(t *testing.T) {
"id", "name", "description", "platform", "subscription_type",
"rate_multiplier", "user_rate_multiplier", "is_exclusive", "models",
"peak_rate_enabled", "peak_start", "peak_end", "peak_rate_multiplier",
- "image_rate_independent", "image_rate_multiplier",
+ "image_rate_independent", "image_rate_multiplier", "long_context_pricing_enabled",
} {
_, exists := decoded[key]
require.Truef(t, exists, "plaza group DTO must expose %q", key)
@@ -109,6 +109,10 @@ func TestToModelPlazaGroupDTO_UserRateAndFieldWhitelist(t *testing.T) {
require.Contains(t, official, "cache_read_price")
_, has1h := official["cache_write_1h_price"]
require.False(t, has1h, "1h 缓存写价为 nil 时应 omitempty")
+ _, hasOfficialIntervals := official["intervals"]
+ require.False(t, hasOfficialIntervals, "官方无阶梯时 intervals 应 omitempty")
+ _, hasBasis := model["long_context_basis"]
+ require.False(t, hasBasis, "单档模型不输出 long_context_basis")
// 无专属倍率:user_rate_multiplier 整个字段省略
dtoNoRate := toModelPlazaGroupDTO(&g, nil)
@@ -124,4 +128,54 @@ func TestToModelPlazaOfficialPricing_NilPassthrough(t *testing.T) {
require.Nil(t, toModelPlazaOfficialPricing(nil))
}
+func TestToModelPlazaGroupDTO_LongContextTiersAndBasis(t *testing.T) {
+ maxTokens := 272000
+ g := service.PlazaGroup{
+ ID: 3, Name: "ladder", Platform: "openai", SubscriptionType: "standard", RateMultiplier: 1,
+ LongContextPricingEnabled: true,
+ Models: []service.PlazaModel{{
+ Name: "gpt-5.4",
+ Platform: "openai",
+ Pricing: &service.ChannelModelPricing{
+ BillingMode: service.BillingModeToken,
+ InputPrice: testPtr(2.5e-6),
+ Intervals: []service.PricingInterval{
+ {MinTokens: 0, MaxTokens: &maxTokens, TierLabel: "≤272K", InputPrice: testPtr(2.5e-6)},
+ {MinTokens: 272000, TierLabel: ">272K", InputPrice: testPtr(5e-6)},
+ },
+ },
+ OfficialPricing: &service.PlazaOfficialPricing{
+ InputPrice: testPtr(2.5e-6),
+ Intervals: []service.PricingInterval{
+ {MinTokens: 0, MaxTokens: &maxTokens, TierLabel: "≤272K", InputPrice: testPtr(2.5e-6)},
+ {MinTokens: 272000, TierLabel: ">272K", InputPrice: testPtr(5e-6)},
+ },
+ },
+ LongContextBasis: service.ContextPricingBasisWholeRequest,
+ }},
+ }
+
+ raw, err := json.Marshal(toModelPlazaGroupDTO(&g, nil))
+ require.NoError(t, err)
+ var decoded map[string]any
+ require.NoError(t, json.Unmarshal(raw, &decoded))
+ require.Equal(t, true, decoded["long_context_pricing_enabled"])
+
+ model := decoded["models"].([]any)[0].(map[string]any)
+ require.Equal(t, "whole_request", model["long_context_basis"])
+
+ pricing := model["pricing"].(map[string]any)
+ paidTiers := pricing["intervals"].([]any)
+ require.Len(t, paidTiers, 2)
+ require.Equal(t, ">272K", paidTiers[1].(map[string]any)["tier_label"])
+
+ official := model["official_pricing"].(map[string]any)
+ officialTiers := official["intervals"].([]any)
+ require.Len(t, officialTiers, 2)
+ first := officialTiers[0].(map[string]any)
+ require.Equal(t, "≤272K", first["tier_label"])
+ require.InDelta(t, 272000, first["max_tokens"].(float64), 0)
+ require.Contains(t, first, "cache_write_price", "区间 DTO 字段齐全(nil 输出 null)")
+}
+
func testPtr(v float64) *float64 { return &v }
diff --git a/backend/internal/service/channel_available.go b/backend/internal/service/channel_available.go
index eeaf7dac2e..6face6e1db 100644
--- a/backend/internal/service/channel_available.go
+++ b/backend/internal/service/channel_available.go
@@ -90,7 +90,7 @@ func (s *ChannelService) ListAvailable(ctx context.Context) ([]AvailableChannel,
ch.normalizeBillingModelSource()
supported := ch.SupportedModels()
- s.fillGlobalPricingFallback(supported)
+ fillGlobalPricingFallback(s.pricingService, supported)
out = append(out, AvailableChannel{
ID: ch.ID,
@@ -117,16 +117,16 @@ func (s *ChannelService) ListAvailable(ctx context.Context) ([]AvailableChannel,
// 1. Pricing == nil(渠道完全没声明该模型的定价条目)
// 2. Pricing 非 nil 但所有价格字段为空(admin UI 建了条目但没填价格)
//
-// 当 s.pricingService 为 nil(测试场景),跳过回落。
-func (s *ChannelService) fillGlobalPricingFallback(models []SupportedModel) {
- if s.pricingService == nil {
+// 当 pricingService 为 nil(测试场景),跳过回落。可用渠道与模型广场共用。
+func fillGlobalPricingFallback(pricingService *PricingService, models []SupportedModel) {
+ if pricingService == nil {
return
}
for i := range models {
if !pricingNeedsFallback(models[i].Pricing) {
continue
}
- lp := s.pricingService.GetModelPricing(models[i].Name)
+ lp := pricingService.GetModelPricing(models[i].Name)
if lp == nil {
continue
}
diff --git a/backend/internal/service/channel_available_test.go b/backend/internal/service/channel_available_test.go
index d59e587ecd..2b7b23f7e5 100644
--- a/backend/internal/service/channel_available_test.go
+++ b/backend/internal/service/channel_available_test.go
@@ -255,7 +255,7 @@ func TestFillGlobalPricingFallback_NilPricing(t *testing.T) {
models := []SupportedModel{
{Name: "claude-opus-4-5", Platform: "anthropic"},
}
- svc.fillGlobalPricingFallback(models)
+ fillGlobalPricingFallback(svc.pricingService, models)
require.NotNil(t, models[0].Pricing)
require.NotNil(t, models[0].Pricing.InputPrice)
require.InDelta(t, 5e-6, *models[0].Pricing.InputPrice, 1e-12)
@@ -281,7 +281,7 @@ func TestFillGlobalPricingFallback_EmptyPricingFillsFromLiteLLM(t *testing.T) {
},
},
}
- svc.fillGlobalPricingFallback(models)
+ fillGlobalPricingFallback(svc.pricingService, models)
require.NotNil(t, models[0].Pricing)
require.Equal(t, BillingModeImage, models[0].Pricing.BillingMode)
require.NotNil(t, models[0].Pricing.ImageOutputPrice)
@@ -302,7 +302,7 @@ func TestFillGlobalPricingFallback_KeepsExistingPrice(t *testing.T) {
models := []SupportedModel{
{Name: "served-model", Platform: "anthropic", Pricing: existing},
}
- svc.fillGlobalPricingFallback(models)
+ fillGlobalPricingFallback(svc.pricingService, models)
require.Same(t, existing, models[0].Pricing)
}
diff --git a/backend/internal/service/channel_plaza.go b/backend/internal/service/channel_plaza.go
deleted file mode 100644
index 1e4dee2717..0000000000
--- a/backend/internal/service/channel_plaza.go
+++ /dev/null
@@ -1,256 +0,0 @@
-package service
-
-import (
- "context"
- "fmt"
- "sort"
- "strings"
-)
-
-// PlazaOfficialPricing 模型广场展示用的 LiteLLM 官方参考价(USD per token)。
-// 字段为 nil 表示官方数据中该项缺失(0 视为未配置)。
-type PlazaOfficialPricing struct {
- InputPrice *float64
- OutputPrice *float64
- CacheWritePrice *float64 // 5m 缓存写入(= LiteLLM cache_creation)
- CacheWrite1hPrice *float64 // 1h 缓存写入(LiteLLM cache_creation_above_1hr)
- CacheReadPrice *float64
-}
-
-// PlazaModel 模型广场中单个模型条目:渠道定价 + 官方参考价。
-type PlazaModel struct {
- Name string
- Platform string
- Pricing *ChannelModelPricing
- OfficialPricing *PlazaOfficialPricing
-}
-
-// PlazaGroup 模型广场中以分组为顶层的条目。
-//
-// 与 AvailableGroupRef 相比多了 Description 与 Models;Models 来自该分组关联渠道的
-// 支持模型(普通分组按分组平台隔离,Composite 分组展开关联渠道已配置的
-// 具体平台),与「可用渠道」页口径一致。
-type PlazaGroup struct {
- ID int64
- Name string
- Description string
- Platform string
- SubscriptionType string
- RateMultiplier float64
- PeakRateEnabled bool
- PeakStart string
- PeakEnd string
- PeakRateMultiplier float64
- IsExclusive bool
- // 图片按次实付倍率:ImageRateIndependent 为 true 时,图片计费模型的实付
- // = 档位价 × ImageRateMultiplier,不乘分组/用户专属倍率(与计费口径一致)。
- ImageRateIndependent bool
- ImageRateMultiplier float64
- Models []PlazaModel
-}
-
-// ListPlazaGroups 返回模型广场数据:每个活跃分组附带其可用模型与定价。
-//
-// 聚合口径与 ListAvailable 一致(Active 渠道、SupportedModels ∪ 全局定价回落、
-// 平台隔离),仅把顶层从渠道换成分组:
-// - 渠道按 lower(name) 排序后遍历,保证同名模型去重结果确定;
-// - 同分组同名模型「先见者胜」,仅当已存条目无定价而新条目有定价时升级替换;
-// - 图片计费模型的档位价按实收口径合成(分组图片价 > 渠道档位价 > 渠道默认按次价,
-// 见 plazaImageDisplayPricing);
-// - 每个模型附带 LiteLLM 官方参考价(查不到为 nil);
-// - 只返回 Models 非空的分组;分组按 RateMultiplier 升序(同倍率按名称),
-// 组内模型按名称排序。
-//
-// 可见性过滤(专属分组)不在此层做,由 handler 按登录态裁剪。
-func (s *ChannelService) ListPlazaGroups(ctx context.Context) ([]PlazaGroup, error) {
- channels, err := s.repo.ListAll(ctx)
- if err != nil {
- return nil, fmt.Errorf("list channels: %w", err)
- }
- groups, err := s.groupRepo.ListActive(ctx)
- if err != nil {
- return nil, fmt.Errorf("list active groups: %w", err)
- }
-
- sort.SliceStable(channels, func(i, j int) bool {
- return strings.ToLower(channels[i].Name) < strings.ToLower(channels[j].Name)
- })
-
- byGroup := make(map[int64]*PlazaGroup, len(groups))
- groupEnt := make(map[int64]*Group, len(groups))
- order := make([]int64, 0, len(groups))
- for i := range groups {
- g := &groups[i]
- byGroup[g.ID] = &PlazaGroup{
- ID: g.ID,
- Name: g.Name,
- Description: g.Description,
- Platform: g.Platform,
- SubscriptionType: g.SubscriptionType,
- RateMultiplier: g.RateMultiplier,
- PeakRateEnabled: g.PeakRateEnabled,
- PeakStart: g.PeakStart,
- PeakEnd: g.PeakEnd,
- PeakRateMultiplier: g.PeakRateMultiplier,
- IsExclusive: g.IsExclusive,
- ImageRateIndependent: g.ImageRateIndependent,
- ImageRateMultiplier: g.ImageRateMultiplier,
- }
- groupEnt[g.ID] = g
- order = append(order, g.ID)
- }
-
- 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 {
- continue
- }
- ch.normalizeBillingModelSource()
- supported := ch.SupportedModels()
- s.fillGlobalPricingFallback(supported)
-
- for _, gid := range ch.GroupIDs {
- pg, ok := byGroup[gid]
- if !ok {
- continue
- }
- idx := modelIdx[gid]
- if idx == nil {
- idx = make(map[modelKey]int, len(supported))
- modelIdx[gid] = idx
- }
- for j := range supported {
- m := supported[j]
- if pg.Platform == PlatformComposite {
- if !isConcreteRequestPlatform(m.Platform) {
- continue
- }
- } else if m.Platform != pg.Platform {
- continue
- }
- pricing := plazaImageDisplayPricing(m.Pricing, groupEnt[gid])
- 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[key] = len(pg.Models)
- pg.Models = append(pg.Models, PlazaModel{
- Name: m.Name,
- Platform: m.Platform,
- Pricing: pricing,
- })
- }
- }
- }
-
- officialMemo := make(map[string]*PlazaOfficialPricing)
- out := make([]PlazaGroup, 0, len(order))
- for _, gid := range order {
- pg := byGroup[gid]
- if len(pg.Models) == 0 {
- continue
- }
- 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)
- }
- out = append(out, *pg)
- }
-
- sort.SliceStable(out, func(i, j int) bool {
- if out[i].RateMultiplier != out[j].RateMultiplier {
- return out[i].RateMultiplier < out[j].RateMultiplier
- }
- return out[i].Name < out[j].Name
- })
- return out, nil
-}
-
-// plazaImageDisplayPricing 为图片计费模型合成展示定价,使档位价与实收口径一致:
-// 每档(1K/2K/4K)单价 = 分组图片价 > 渠道同档位价 > 渠道默认按次价,无价的档不展示。
-// 分组未配任何图片价、或定价非图片模式时原样返回。返回克隆,不修改入参
-// (渠道定价指针指向缓存共享数据)。
-func plazaImageDisplayPricing(p *ChannelModelPricing, g *Group) *ChannelModelPricing {
- if p == nil || g == nil || p.BillingMode != BillingModeImage {
- return p
- }
- if g.ImagePrice1K == nil && g.ImagePrice2K == nil && g.ImagePrice4K == nil {
- return p
- }
- channelTierPrice := func(label string) *float64 {
- for i := range p.Intervals {
- if p.Intervals[i].TierLabel == label && p.Intervals[i].PerRequestPrice != nil {
- return p.Intervals[i].PerRequestPrice
- }
- }
- return p.PerRequestPrice
- }
- tiers := []struct {
- label string
- groupPrice *float64
- }{
- {"1K", g.ImagePrice1K},
- {"2K", g.ImagePrice2K},
- {"4K", g.ImagePrice4K},
- }
- clone := *p
- clone.Intervals = make([]PricingInterval, 0, len(tiers))
- for i, t := range tiers {
- price := t.groupPrice
- if price == nil {
- price = channelTierPrice(t.label)
- }
- if price == nil {
- continue
- }
- v := *price
- clone.Intervals = append(clone.Intervals, PricingInterval{
- TierLabel: t.label,
- PerRequestPrice: &v,
- SortOrder: i,
- })
- }
- return &clone
-}
-
-// lookupOfficialPricing 查询模型的 LiteLLM 官方参考价,带 memo 避免同名模型重复转换。
-// pricingService 为 nil(测试场景)或查不到时返回 nil。
-func (s *ChannelService) lookupOfficialPricing(modelName string, memo map[string]*PlazaOfficialPricing) *PlazaOfficialPricing {
- if s.pricingService == nil {
- return nil
- }
- if cached, ok := memo[modelName]; ok {
- return cached
- }
- var result *PlazaOfficialPricing
- if lp := s.pricingService.GetModelPricing(modelName); lp != nil && !lp.TokenPricingAbsent {
- result = &PlazaOfficialPricing{
- InputPrice: nonZeroPtr(lp.InputCostPerToken),
- OutputPrice: nonZeroPtr(lp.OutputCostPerToken),
- CacheWritePrice: nonZeroPtr(lp.CacheCreationInputTokenCost),
- CacheWrite1hPrice: nonZeroPtr(lp.CacheCreationInputTokenCostAbove1hr),
- CacheReadPrice: nonZeroPtr(lp.CacheReadInputTokenCost),
- }
- if result.InputPrice == nil && result.OutputPrice == nil &&
- result.CacheWritePrice == nil && result.CacheWrite1hPrice == nil && result.CacheReadPrice == nil {
- result = nil
- }
- }
- memo[modelName] = result
- return result
-}
diff --git a/backend/internal/service/model_plaza_service.go b/backend/internal/service/model_plaza_service.go
new file mode 100644
index 0000000000..10a8eda559
--- /dev/null
+++ b/backend/internal/service/model_plaza_service.go
@@ -0,0 +1,361 @@
+package service
+
+import (
+ "context"
+ "fmt"
+ "sort"
+ "strings"
+)
+
+// PlazaOfficialPricing 模型广场展示用的官方参考价(USD per token),与计费同源:
+// LiteLLM → 内置兜底价卡 → 模型策略。字段为 nil 表示该项缺失(0 视为未配置)。
+type PlazaOfficialPricing struct {
+ InputPrice *float64
+ OutputPrice *float64
+ CacheWritePrice *float64 // 5m 缓存写入(= LiteLLM cache_creation)
+ CacheWrite1hPrice *float64 // 1h 缓存写入,仅计费会区分 5m/1h 时给出
+ CacheReadPrice *float64
+ // Intervals 官方长上下文阶梯(多档时给出),不受分组开关影响。
+ Intervals []PricingInterval
+}
+
+// PlazaModel 模型广场中单个模型条目:按实收口径合成的展示定价 + 官方参考价。
+type PlazaModel struct {
+ Name string
+ Platform string
+ Pricing *ChannelModelPricing
+ OfficialPricing *PlazaOfficialPricing
+ // LongContextBasis 多档时的计价基准(整单 / 仅超出部分),单档为空。
+ LongContextBasis ContextPricingBasis
+}
+
+// PlazaGroup 模型广场中以分组为顶层的条目。
+//
+// 与 AvailableGroupRef 相比多了 Description 与 Models;Models 来自该分组关联渠道的
+// 支持模型(普通分组按分组平台隔离,Composite 分组展开关联渠道已配置的
+// 具体平台),与「可用渠道」页口径一致。
+type PlazaGroup struct {
+ ID int64
+ Name string
+ Description string
+ Platform string
+ SubscriptionType string
+ RateMultiplier float64
+ PeakRateEnabled bool
+ PeakStart string
+ PeakEnd string
+ PeakRateMultiplier float64
+ IsExclusive bool
+ // 图片按次实付倍率:ImageRateIndependent 为 true 时,图片计费模型的实付
+ // = 档位价 × ImageRateMultiplier,不乘分组/用户专属倍率(与计费口径一致)。
+ ImageRateIndependent bool
+ ImageRateMultiplier float64
+ // LongContextPricingEnabled 分组是否按上下文长度应用阶梯价;关闭时模型展示的是最低档。
+ LongContextPricingEnabled bool
+ Models []PlazaModel
+}
+
+// ModelPlazaService 聚合模型广场数据。
+//
+// 模型枚举来自渠道配置;token 模型的展示单价与阶梯由 BillingService 的阶梯表
+// 查询给出(与扣费走同一条解析链与计费函数),图片/按次模型沿用渠道/分组档位价。
+type ModelPlazaService struct {
+ channelRepo ChannelRepository
+ groupRepo GroupRepository
+ pricingService *PricingService
+ billingService *BillingService
+ resolver *ModelPricingResolver
+}
+
+// NewModelPlazaService 创建模型广场服务。
+func NewModelPlazaService(
+ channelRepo ChannelRepository,
+ groupRepo GroupRepository,
+ pricingService *PricingService,
+ billingService *BillingService,
+ resolver *ModelPricingResolver,
+) *ModelPlazaService {
+ return &ModelPlazaService{
+ channelRepo: channelRepo,
+ groupRepo: groupRepo,
+ pricingService: pricingService,
+ billingService: billingService,
+ resolver: resolver,
+ }
+}
+
+// ListGroups 返回模型广场数据:每个活跃分组附带其可用模型与定价。
+//
+// 模型枚举口径与 ListAvailable 一致(Active 渠道、SupportedModels ∪ 全局定价回落、
+// 平台隔离),仅把顶层从渠道换成分组:
+// - 渠道按 lower(name) 排序后遍历,保证同名模型去重结果确定;
+// - 同分组同名模型「先见者胜」,仅当已存条目无定价而新条目有定价时升级替换;
+// - token 模型的单价与阶梯按实收口径合成(见 ResolveContextPricingSchedule),
+// 图片计费模型的档位价按实收口径合成(见 plazaImageDisplayPricing);
+// - 每个模型附带官方参考价(查不到为 nil);
+// - 只返回 Models 非空的分组;分组按 RateMultiplier 升序(同倍率按名称),
+// 组内模型按名称排序。
+//
+// 可见性过滤(专属分组)不在此层做,由 handler 按登录态裁剪。
+func (s *ModelPlazaService) ListGroups(ctx context.Context) ([]PlazaGroup, error) {
+ channels, err := s.channelRepo.ListAll(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("list channels: %w", err)
+ }
+ groups, err := s.groupRepo.ListActive(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("list active groups: %w", err)
+ }
+
+ sort.SliceStable(channels, func(i, j int) bool {
+ return strings.ToLower(channels[i].Name) < strings.ToLower(channels[j].Name)
+ })
+
+ byGroup := make(map[int64]*PlazaGroup, len(groups))
+ groupEnt := make(map[int64]*Group, len(groups))
+ order := make([]int64, 0, len(groups))
+ for i := range groups {
+ g := &groups[i]
+ byGroup[g.ID] = &PlazaGroup{
+ ID: g.ID,
+ Name: g.Name,
+ Description: g.Description,
+ Platform: g.Platform,
+ SubscriptionType: g.SubscriptionType,
+ RateMultiplier: g.RateMultiplier,
+ PeakRateEnabled: g.PeakRateEnabled,
+ PeakStart: g.PeakStart,
+ PeakEnd: g.PeakEnd,
+ PeakRateMultiplier: g.PeakRateMultiplier,
+ IsExclusive: g.IsExclusive,
+ ImageRateIndependent: g.ImageRateIndependent,
+ ImageRateMultiplier: g.ImageRateMultiplier,
+ LongContextPricingEnabled: g.LongContextPricingEnabled,
+ }
+ groupEnt[g.ID] = g
+ order = append(order, g.ID)
+ }
+
+ 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 {
+ continue
+ }
+ ch.normalizeBillingModelSource()
+ supported := ch.SupportedModels()
+ fillGlobalPricingFallback(s.pricingService, supported)
+
+ for _, gid := range ch.GroupIDs {
+ pg, ok := byGroup[gid]
+ if !ok {
+ continue
+ }
+ idx := modelIdx[gid]
+ if idx == nil {
+ idx = make(map[modelKey]int, len(supported))
+ modelIdx[gid] = idx
+ }
+ for j := range supported {
+ m := supported[j]
+ if pg.Platform == PlatformComposite {
+ if !isConcreteRequestPlatform(m.Platform) {
+ continue
+ }
+ } else if m.Platform != pg.Platform {
+ continue
+ }
+ key := modelKey{platform: m.Platform, name: m.Name}
+ if at, seen := idx[key]; seen {
+ // 先见者胜;仅当已存条目无定价而新条目有定价时升级。
+ if pg.Models[at].Pricing == nil && m.Pricing != nil {
+ pg.Models[at].Pricing = m.Pricing
+ }
+ continue
+ }
+ idx[key] = len(pg.Models)
+ pg.Models = append(pg.Models, PlazaModel{
+ Name: m.Name,
+ Platform: m.Platform,
+ Pricing: m.Pricing,
+ })
+ }
+ }
+ }
+
+ officialMemo := make(map[string]*PlazaOfficialPricing)
+ out := make([]PlazaGroup, 0, len(order))
+ for _, gid := range order {
+ pg := byGroup[gid]
+ if len(pg.Models) == 0 {
+ continue
+ }
+ 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
+ })
+ g := groupEnt[gid]
+ for j := range pg.Models {
+ s.fillDisplayPricing(ctx, &pg.Models[j], g)
+ pg.Models[j].OfficialPricing = s.lookupOfficialPricing(ctx, pg.Models[j].Name, officialMemo)
+ }
+ out = append(out, *pg)
+ }
+
+ sort.SliceStable(out, func(i, j int) bool {
+ if out[i].RateMultiplier != out[j].RateMultiplier {
+ return out[i].RateMultiplier < out[j].RateMultiplier
+ }
+ return out[i].Name < out[j].Name
+ })
+ return out, nil
+}
+
+// fillDisplayPricing 把模型的展示定价换成实收口径:
+// token 模型取计费阶梯表(单价与档位均由真实计费函数得出),
+// 图片/按次模型(或阶梯表不可用时)沿用渠道定价与分组图片档位价。
+func (s *ModelPlazaService) fillDisplayPricing(ctx context.Context, m *PlazaModel, g *Group) {
+ if s.billingService != nil && s.resolver != nil {
+ sched, err := s.billingService.ResolveContextPricingSchedule(ctx, s.resolver, ContextPricingScheduleInput{
+ Model: m.Name,
+ Group: g,
+ Platform: m.Platform,
+ })
+ if err == nil && sched != nil && len(sched.Tiers) > 0 {
+ m.Pricing = plazaPricingFromSchedule(m.Pricing, sched)
+ if len(sched.Tiers) > 1 {
+ m.LongContextBasis = sched.Basis
+ }
+ return
+ }
+ }
+ m.Pricing = plazaImageDisplayPricing(m.Pricing, g)
+}
+
+// plazaPricingFromSchedule 把阶梯表压成展示用的 ChannelModelPricing:
+// 平价取首档单价,多档时 Intervals 逐档给出绝对单价;图片/按次字段沿用原始定价。
+func plazaPricingFromSchedule(raw *ChannelModelPricing, sched *ContextPricingSchedule) *ChannelModelPricing {
+ out := &ChannelModelPricing{BillingMode: BillingModeToken}
+ if raw != nil {
+ out.ImageInputPrice = raw.ImageInputPrice
+ out.ImageOutputPrice = raw.ImageOutputPrice
+ out.PerRequestPrice = raw.PerRequestPrice
+ }
+ first := sched.Tiers[0]
+ out.InputPrice = first.Input
+ out.OutputPrice = first.Output
+ out.CacheWritePrice = first.CacheWrite
+ out.CacheReadPrice = first.CacheRead
+ if len(sched.Tiers) > 1 {
+ out.Intervals = plazaIntervalsFromTiers(sched.Tiers)
+ }
+ return out
+}
+
+func plazaIntervalsFromTiers(tiers []ContextPricingTier) []PricingInterval {
+ intervals := make([]PricingInterval, 0, len(tiers))
+ for i, t := range tiers {
+ intervals = append(intervals, PricingInterval{
+ MinTokens: t.MinTokens,
+ MaxTokens: t.MaxTokens,
+ TierLabel: t.Label,
+ InputPrice: t.Input,
+ OutputPrice: t.Output,
+ CacheWritePrice: t.CacheWrite,
+ CacheReadPrice: t.CacheRead,
+ SortOrder: i,
+ })
+ }
+ return intervals
+}
+
+// plazaImageDisplayPricing 为图片计费模型合成展示定价,使档位价与实收口径一致:
+// 每档(1K/2K/4K)单价 = 分组图片价 > 渠道同档位价 > 渠道默认按次价,无价的档不展示。
+// 分组未配任何图片价、或定价非图片模式时原样返回。返回克隆,不修改入参
+// (渠道定价指针指向缓存共享数据)。
+func plazaImageDisplayPricing(p *ChannelModelPricing, g *Group) *ChannelModelPricing {
+ if p == nil || g == nil || p.BillingMode != BillingModeImage {
+ return p
+ }
+ if g.ImagePrice1K == nil && g.ImagePrice2K == nil && g.ImagePrice4K == nil {
+ return p
+ }
+ channelTierPrice := func(label string) *float64 {
+ for i := range p.Intervals {
+ if p.Intervals[i].TierLabel == label && p.Intervals[i].PerRequestPrice != nil {
+ return p.Intervals[i].PerRequestPrice
+ }
+ }
+ return p.PerRequestPrice
+ }
+ tiers := []struct {
+ label string
+ groupPrice *float64
+ }{
+ {"1K", g.ImagePrice1K},
+ {"2K", g.ImagePrice2K},
+ {"4K", g.ImagePrice4K},
+ }
+ clone := *p
+ clone.Intervals = make([]PricingInterval, 0, len(tiers))
+ for i, t := range tiers {
+ price := t.groupPrice
+ if price == nil {
+ price = channelTierPrice(t.label)
+ }
+ if price == nil {
+ continue
+ }
+ v := *price
+ clone.Intervals = append(clone.Intervals, PricingInterval{
+ TierLabel: t.label,
+ PerRequestPrice: &v,
+ SortOrder: i,
+ })
+ }
+ return &clone
+}
+
+// lookupOfficialPricing 查询模型的官方参考价(与计费同源:LiteLLM → 内置兜底 → 模型策略),
+// 带 memo 避免同名模型重复解析。官方阶梯按无分组、无渠道的口径查阶梯表。
+// billingService 为 nil(测试场景)或查不到时返回 nil。
+func (s *ModelPlazaService) lookupOfficialPricing(ctx context.Context, modelName string, memo map[string]*PlazaOfficialPricing) *PlazaOfficialPricing {
+ if s.billingService == nil {
+ return nil
+ }
+ if cached, ok := memo[modelName]; ok {
+ return cached
+ }
+ var result *PlazaOfficialPricing
+ if mp, err := s.billingService.GetModelPricing(modelName); err == nil && mp != nil {
+ result = &PlazaOfficialPricing{
+ InputPrice: nonZeroPtr(mp.InputPricePerToken),
+ OutputPrice: nonZeroPtr(mp.OutputPricePerToken),
+ CacheWritePrice: nonZeroPtr(mp.CacheCreationPricePerToken),
+ CacheReadPrice: nonZeroPtr(mp.CacheReadPricePerToken),
+ }
+ // 计费只在支持 5m/1h 分档时使用 1h 价,其余情况 1h 价对用户无意义。
+ if mp.SupportsCacheBreakdown {
+ result.CacheWrite1hPrice = nonZeroPtr(mp.CacheCreation1hPrice)
+ }
+ if s.resolver != nil {
+ sched, schedErr := s.billingService.ResolveContextPricingSchedule(ctx, s.resolver, ContextPricingScheduleInput{Model: modelName})
+ if schedErr == nil && sched != nil && len(sched.Tiers) > 1 {
+ result.Intervals = plazaIntervalsFromTiers(sched.Tiers)
+ }
+ }
+ if result.InputPrice == nil && result.OutputPrice == nil && result.CacheWritePrice == nil &&
+ result.CacheWrite1hPrice == nil && result.CacheReadPrice == nil && len(result.Intervals) == 0 {
+ result = nil
+ }
+ }
+ memo[modelName] = result
+ return result
+}
diff --git a/backend/internal/service/channel_plaza_test.go b/backend/internal/service/model_plaza_service_test.go
similarity index 56%
rename from backend/internal/service/channel_plaza_test.go
rename to backend/internal/service/model_plaza_service_test.go
index 554654149c..df4a20cdf7 100644
--- a/backend/internal/service/channel_plaza_test.go
+++ b/backend/internal/service/model_plaza_service_test.go
@@ -7,17 +7,16 @@ import (
"errors"
"testing"
+ "github.com/Wei-Shaw/sub2api/internal/config"
"github.com/stretchr/testify/require"
)
-// newPlazaChannelService 构造 ListPlazaGroups 测试用的 ChannelService。
-func newPlazaChannelService(channels []Channel, groups []Group, pricing *PricingService) *ChannelService {
+// newPlazaService 构造 ListGroups 测试用的 ModelPlazaService(不接计费服务:展示定价原样透传)。
+func newPlazaService(channels []Channel, groups []Group, pricing *PricingService) *ModelPlazaService {
repo := &mockChannelRepository{
listAllFn: func(ctx context.Context) ([]Channel, error) { return channels, nil },
}
- svc := NewChannelService(repo, &stubGroupRepoForAvailable{activeGroups: groups}, nil, nil)
- svc.pricingService = pricing
- return svc
+ return NewModelPlazaService(repo, &stubGroupRepoForAvailable{activeGroups: groups}, pricing, nil, nil)
}
func plazaPricedChannel(id int64, name string, groupIDs []int64, platform string, models ...string) Channel {
@@ -46,8 +45,8 @@ func TestListPlazaGroups_GroupCentricAggregation(t *testing.T) {
{ID: 10, Name: "g-main", Description: "desc", Platform: "anthropic", RateMultiplier: 1},
{ID: 20, Name: "g-empty", Platform: "anthropic", RateMultiplier: 0.5},
}
- svc := newPlazaChannelService(channels, groups, nil)
- out, err := svc.ListPlazaGroups(context.Background())
+ svc := newPlazaService(channels, groups, nil)
+ out, err := svc.ListGroups(context.Background())
require.NoError(t, err)
require.Len(t, out, 1, "无模型的分组不应返回")
require.Equal(t, int64(10), out[0].ID)
@@ -71,8 +70,8 @@ func TestListPlazaGroups_DedupFirstWinsWithPricingUpgrade(t *testing.T) {
groups := []Group{{ID: 10, Name: "g", Platform: "anthropic", RateMultiplier: 1}}
// alpha(无价)按名称序先于 beta(有价):先见者无价,应被有价条目升级。
- svc := newPlazaChannelService([]Channel{priced, unpriced}, groups, nil)
- out, err := svc.ListPlazaGroups(context.Background())
+ svc := newPlazaService([]Channel{priced, unpriced}, groups, nil)
+ out, err := svc.ListGroups(context.Background())
require.NoError(t, err)
require.Len(t, out, 1)
require.Len(t, out[0].Models, 1)
@@ -93,8 +92,8 @@ func TestListPlazaGroups_PlatformIsolation(t *testing.T) {
{ID: 10, Name: "g-claude", Platform: "anthropic", RateMultiplier: 1},
{ID: 20, Name: "g-gpt", Platform: "openai", RateMultiplier: 1},
}
- svc := newPlazaChannelService([]Channel{ch}, groups, nil)
- out, err := svc.ListPlazaGroups(context.Background())
+ svc := newPlazaService([]Channel{ch}, groups, nil)
+ out, err := svc.ListGroups(context.Background())
require.NoError(t, err)
require.Len(t, out, 2)
byName := map[string][]PlazaModel{}
@@ -122,7 +121,7 @@ func TestListPlazaGroups_CompositeIncludesConfiguredConcretePlatforms(t *testing
}
groups := []Group{{ID: 10, Name: "composite", Platform: PlatformComposite, RateMultiplier: 1}}
- out, err := newPlazaChannelService([]Channel{ch}, groups, nil).ListPlazaGroups(context.Background())
+ out, err := newPlazaService([]Channel{ch}, groups, nil).ListGroups(context.Background())
require.NoError(t, err)
require.Len(t, out, 1)
@@ -146,7 +145,7 @@ func TestListPlazaGroups_CompositeAndOrdinaryGroupsDoNotLeakPlatforms(t *testing
{ID: 20, Name: "composite", Platform: PlatformComposite, RateMultiplier: 1},
}
- out, err := newPlazaChannelService([]Channel{ch}, groups, nil).ListPlazaGroups(context.Background())
+ out, err := newPlazaService([]Channel{ch}, groups, nil).ListGroups(context.Background())
require.NoError(t, err)
require.Len(t, out, 2)
@@ -173,8 +172,8 @@ func TestListPlazaGroups_InactiveChannelSkipped(t *testing.T) {
inactive := plazaPricedChannel(1, "off", []int64{10}, "anthropic", "claude-sonnet")
inactive.Status = "inactive"
groups := []Group{{ID: 10, Name: "g", Platform: "anthropic", RateMultiplier: 1}}
- svc := newPlazaChannelService([]Channel{inactive}, groups, nil)
- out, err := svc.ListPlazaGroups(context.Background())
+ svc := newPlazaService([]Channel{inactive}, groups, nil)
+ out, err := svc.ListGroups(context.Background())
require.NoError(t, err)
require.Empty(t, out)
}
@@ -188,8 +187,8 @@ func TestListPlazaGroups_SortedByRateMultiplierAsc(t *testing.T) {
{ID: 20, Name: "a-standard", Platform: "anthropic", RateMultiplier: 1},
{ID: 30, Name: "cheap", Platform: "anthropic", RateMultiplier: 0.5},
}
- svc := newPlazaChannelService(channels, groups, nil)
- out, err := svc.ListPlazaGroups(context.Background())
+ svc := newPlazaService(channels, groups, nil)
+ out, err := svc.ListGroups(context.Background())
require.NoError(t, err)
require.Len(t, out, 3)
require.Equal(t, "cheap", out[0].Name, "倍率低者在前")
@@ -213,8 +212,11 @@ func TestListPlazaGroups_OfficialPricingFill(t *testing.T) {
plazaPricedChannel(1, "ch", []int64{10}, "anthropic", "claude-sonnet", "unknown-model", "token-absent"),
}
groups := []Group{{ID: 10, Name: "g", Platform: "anthropic", RateMultiplier: 1}}
- svc := newPlazaChannelService(channels, groups, pricingSvc)
- out, err := svc.ListPlazaGroups(context.Background())
+ svc := newPlazaService(channels, groups, pricingSvc)
+ // 官方价与计费同源:需要计费服务与解析器(官方参考不查渠道,解析器无需渠道服务)。
+ svc.billingService = NewBillingService(&config.Config{}, pricingSvc)
+ svc.resolver = NewModelPricingResolver(nil, svc.billingService)
+ out, err := svc.ListGroups(context.Background())
require.NoError(t, err)
require.Len(t, out, 1)
require.Len(t, out[0].Models, 3)
@@ -256,8 +258,8 @@ func TestListPlazaGroups_GroupImagePriceOverridesChannelPricing(t *testing.T) {
ImagePrice1K: &imgPrice, ImageRateIndependent: true, ImageRateMultiplier: 1},
{ID: 20, Name: "g-plain", Platform: "openai", RateMultiplier: 0.1},
}
- svc := newPlazaChannelService(channels, groups, nil)
- out, err := svc.ListPlazaGroups(context.Background())
+ svc := newPlazaService(channels, groups, nil)
+ out, err := svc.ListGroups(context.Background())
require.NoError(t, err)
require.Len(t, out, 2)
byName := map[string]PlazaGroup{}
@@ -298,8 +300,8 @@ func TestListPlazaGroups_GroupImagePriceIgnoredForNonImageModes(t *testing.T) {
imgPrice := 0.02
channels := []Channel{plazaPricedChannel(1, "ch", []int64{10}, "openai", "gpt-5")}
groups := []Group{{ID: 10, Name: "g", Platform: "openai", RateMultiplier: 1, ImagePrice1K: &imgPrice}}
- svc := newPlazaChannelService(channels, groups, nil)
- out, err := svc.ListPlazaGroups(context.Background())
+ svc := newPlazaService(channels, groups, nil)
+ out, err := svc.ListGroups(context.Background())
require.NoError(t, err)
require.Len(t, out, 1)
p := out[0].Models[0].Pricing
@@ -314,17 +316,156 @@ func TestListPlazaGroups_RepoErrorsPropagate(t *testing.T) {
repo := &mockChannelRepository{
listAllFn: func(ctx context.Context) ([]Channel, error) { return nil, sentinel },
}
- svc := NewChannelService(repo, &stubGroupRepoForAvailable{}, nil, nil)
- out, err := svc.ListPlazaGroups(context.Background())
+ svc := NewModelPlazaService(repo, &stubGroupRepoForAvailable{}, nil, nil, nil)
+ out, err := svc.ListGroups(context.Background())
require.Nil(t, out)
require.ErrorIs(t, err, sentinel)
- svc2 := NewChannelService(
+ svc2 := NewModelPlazaService(
&mockChannelRepository{listAllFn: func(ctx context.Context) ([]Channel, error) { return nil, nil }},
&stubGroupRepoForAvailable{listActiveErr: sentinel},
- nil, nil,
+ nil, nil, nil,
)
- out2, err2 := svc2.ListPlazaGroups(context.Background())
+ out2, err2 := svc2.ListGroups(context.Background())
require.Nil(t, out2)
require.ErrorIs(t, err2, sentinel)
}
+
+// newPlazaServiceWithBilling 构造接入计费服务与解析器的广场服务:解析器的渠道服务与广场共用同一份渠道数据。
+func newPlazaServiceWithBilling(channels []Channel, groups []Group, groupPlatforms map[int64]string, catalog *PricingService) *ModelPlazaService {
+ repo := &mockChannelRepository{
+ listAllFn: func(ctx context.Context) ([]Channel, error) { return channels, nil },
+ getGroupPlatformsFn: func(ctx context.Context, _ []int64) (map[int64]string, error) {
+ return groupPlatforms, nil
+ },
+ }
+ cs := NewChannelService(repo, nil, nil, nil)
+ bs := NewBillingService(&config.Config{}, catalog)
+ return NewModelPlazaService(repo, &stubGroupRepoForAvailable{activeGroups: groups}, catalog, bs, NewModelPricingResolver(cs, bs))
+}
+
+func plazaModelsByName(models []PlazaModel) map[string]PlazaModel {
+ out := make(map[string]PlazaModel, len(models))
+ for _, m := range models {
+ out[m.Name] = m
+ }
+ return out
+}
+
+func TestListGroups_TokenLadderFollowsGroupToggle(t *testing.T) {
+ // 同一渠道挂开启/关闭阶梯的两个分组:实付档位随分组开关,官方阶梯不受影响。
+ channels := []Channel{{
+ ID: 1, Name: "ch", Status: StatusActive, GroupIDs: []int64{10, 20},
+ ModelPricing: []ChannelModelPricing{{Platform: PlatformOpenAI, Models: []string{"gpt-5.4"}, BillingMode: BillingModeToken}},
+ }}
+ groups := []Group{
+ {ID: 10, Name: "on", Platform: PlatformOpenAI, RateMultiplier: 1, LongContextPricingEnabled: true},
+ {ID: 20, Name: "off", Platform: PlatformOpenAI, RateMultiplier: 2, LongContextPricingEnabled: false},
+ }
+ svc := newPlazaServiceWithBilling(channels, groups, map[int64]string{10: PlatformOpenAI, 20: PlatformOpenAI}, nil)
+ out, err := svc.ListGroups(context.Background())
+ require.NoError(t, err)
+ require.Len(t, out, 2)
+
+ on, off := out[0], out[1]
+ require.True(t, on.LongContextPricingEnabled)
+ require.False(t, off.LongContextPricingEnabled)
+
+ onModel := on.Models[0]
+ require.Equal(t, ContextPricingBasisWholeRequest, onModel.LongContextBasis)
+ require.Len(t, onModel.Pricing.Intervals, 2)
+ require.Equal(t, "≤272K", onModel.Pricing.Intervals[0].TierLabel)
+ require.Equal(t, ">272K", onModel.Pricing.Intervals[1].TierLabel)
+ require.InDelta(t, 2.5e-6, *onModel.Pricing.InputPrice, 1e-15)
+ require.InDelta(t, 5e-6, *onModel.Pricing.Intervals[1].InputPrice, 1e-15)
+ require.InDelta(t, 22.5e-6, *onModel.Pricing.Intervals[1].OutputPrice, 1e-15)
+ require.InDelta(t, 5e-6, *onModel.Pricing.Intervals[1].CacheWritePrice, 1e-15)
+ require.InDelta(t, 0.5e-6, *onModel.Pricing.Intervals[1].CacheReadPrice, 1e-15)
+
+ offModel := off.Models[0]
+ require.Empty(t, offModel.LongContextBasis)
+ require.Empty(t, offModel.Pricing.Intervals)
+ require.InDelta(t, 2.5e-6, *offModel.Pricing.InputPrice, 1e-15)
+
+ for _, m := range []PlazaModel{onModel, offModel} {
+ require.NotNil(t, m.OfficialPricing)
+ require.Len(t, m.OfficialPricing.Intervals, 2, "官方阶梯不受分组开关影响")
+ require.InDelta(t, 5e-6, *m.OfficialPricing.Intervals[1].InputPrice, 1e-15)
+ require.InDelta(t, 2.5e-6, *m.OfficialPricing.InputPrice, 1e-15)
+ }
+}
+
+func TestListGroups_GeminiLegacyRuleShownAsMarginal(t *testing.T) {
+ channels := []Channel{{
+ ID: 1, Name: "ch", Status: StatusActive, GroupIDs: []int64{10},
+ ModelMapping: map[string]map[string]string{PlatformGemini: {"gemini-2.5-pro": "gemini-2.5-pro"}},
+ }}
+ groups := []Group{{ID: 10, Name: "g", Platform: PlatformGemini, RateMultiplier: 1, LongContextPricingEnabled: true}}
+ svc := newPlazaServiceWithBilling(channels, groups, map[int64]string{10: PlatformGemini}, geminiCatalogStub())
+ out, err := svc.ListGroups(context.Background())
+ require.NoError(t, err)
+ require.Len(t, out, 1)
+ m := out[0].Models[0]
+ require.Equal(t, ContextPricingBasisMarginal, m.LongContextBasis)
+ require.Len(t, m.Pricing.Intervals, 2)
+ require.Equal(t, "≤200K", m.Pricing.Intervals[0].TierLabel)
+ require.Equal(t, ">200K", m.Pricing.Intervals[1].TierLabel)
+ require.InDelta(t, 2.5e-6, *m.Pricing.Intervals[1].InputPrice, 1e-15)
+ require.InDelta(t, 10e-6, *m.Pricing.Intervals[1].OutputPrice, 1e-15)
+ // 官方参考不套用站内旧规则
+ require.NotNil(t, m.OfficialPricing)
+ require.Empty(t, m.OfficialPricing.Intervals)
+}
+
+func TestListGroups_GroupTokenCardOverridesChannelPricing(t *testing.T) {
+ channels := []Channel{plazaPricedChannel(1, "ch", []int64{10}, PlatformAnthropic, "claude-sonnet-4")}
+ groups := []Group{{
+ ID: 10, Name: "g", Platform: PlatformAnthropic, RateMultiplier: 1, LongContextPricingEnabled: true,
+ ModelPricing: []ChannelModelPricing{{Models: []string{"claude-sonnet-*"}, BillingMode: BillingModeToken, InputPrice: testPtrFloat64(1e-6)}},
+ }}
+ svc := newPlazaServiceWithBilling(channels, groups, map[int64]string{10: PlatformAnthropic}, nil)
+ out, err := svc.ListGroups(context.Background())
+ require.NoError(t, err)
+ m := out[0].Models[0]
+ require.InDelta(t, 1e-6, *m.Pricing.InputPrice, 1e-15, "分组价卡优先于渠道平价")
+ require.InDelta(t, 15e-6, *m.Pricing.OutputPrice, 1e-15, "卡未配置的项回落目录价")
+ require.Empty(t, m.Pricing.Intervals)
+}
+
+func TestListGroups_ImageModelKeepsTierSynthesisWithBilling(t *testing.T) {
+ channels := []Channel{{
+ ID: 1, Name: "ch", Status: StatusActive, GroupIDs: []int64{10},
+ ModelPricing: []ChannelModelPricing{{
+ Platform: PlatformOpenAI, Models: []string{"gpt-image-2"}, BillingMode: BillingModeImage,
+ PerRequestPrice: testPtrFloat64(0.04),
+ }},
+ }}
+ groups := []Group{{
+ ID: 10, Name: "g", Platform: PlatformOpenAI, RateMultiplier: 1, LongContextPricingEnabled: true,
+ ImagePrice1K: testPtrFloat64(0.02),
+ }}
+ svc := newPlazaServiceWithBilling(channels, groups, map[int64]string{10: PlatformOpenAI}, nil)
+ out, err := svc.ListGroups(context.Background())
+ require.NoError(t, err)
+ m := out[0].Models[0]
+ require.Equal(t, BillingModeImage, m.Pricing.BillingMode)
+ require.Empty(t, m.LongContextBasis)
+ require.Len(t, m.Pricing.Intervals, 3)
+ require.InDelta(t, 0.02, *m.Pricing.Intervals[0].PerRequestPrice, 1e-12)
+ require.InDelta(t, 0.04, *m.Pricing.Intervals[1].PerRequestPrice, 1e-12)
+}
+
+func TestListGroups_CatalogMissingStillShowsChannelFlatPricing(t *testing.T) {
+ // 目录查不到的模型:计费按渠道平价(未配置项 $0),广场单档展示渠道平价,官方价为空。
+ channels := []Channel{plazaPricedChannel(1, "ch", []int64{10}, PlatformAnthropic, "unknown-model-xyz")}
+ groups := []Group{{ID: 10, Name: "g", Platform: PlatformAnthropic, RateMultiplier: 1, LongContextPricingEnabled: true}}
+ svc := newPlazaServiceWithBilling(channels, groups, map[int64]string{10: PlatformAnthropic}, nil)
+ out, err := svc.ListGroups(context.Background())
+ require.NoError(t, err)
+ m := out[0].Models[0]
+ require.NotNil(t, m.Pricing)
+ require.InDelta(t, 3e-6, *m.Pricing.InputPrice, 1e-15)
+ require.Empty(t, m.Pricing.Intervals)
+ require.Nil(t, m.Pricing.CacheWritePrice, "目录无价且渠道未配置 → 无价")
+ require.Nil(t, m.OfficialPricing)
+}
diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go
index 3e469a95c5..17a68799c4 100644
--- a/backend/internal/service/wire.go
+++ b/backend/internal/service/wire.go
@@ -908,6 +908,7 @@ var ProviderSet = wire.NewSet(
NewChannelService,
wire.Bind(new(ChannelCacheInvalidator), new(*ChannelService)),
NewModelPricingResolver,
+ NewModelPlazaService,
NewContentModerationService,
NewAffiliateService,
ProvidePaymentConfigService,
diff --git a/frontend/src/api/modelPlaza.ts b/frontend/src/api/modelPlaza.ts
index c17b3aa1a7..f881eab708 100644
--- a/frontend/src/api/modelPlaza.ts
+++ b/frontend/src/api/modelPlaza.ts
@@ -5,9 +5,9 @@
*/
import { apiClient } from './client'
-import type { UserSupportedModelPricing } from './channels'
+import type { UserPricingInterval, UserSupportedModelPricing } from './channels'
-/** LiteLLM 官方参考价(USD per token,字段缺失 = 官方数据未覆盖)。 */
+/** 官方参考价(USD per token,与计费目录同源;字段缺失 = 目录未覆盖)。 */
export interface PlazaOfficialPricing {
input_price: number | null
output_price: number | null
@@ -16,13 +16,25 @@ export interface PlazaOfficialPricing {
/** 1h 缓存写入(LiteLLM cache_creation_above_1hr),多数模型缺失。 */
cache_write_1h_price?: number | null
cache_read_price: number | null
+ /** 官方长上下文阶梯(多档模型才有),不受分组开关影响。 */
+ intervals?: UserPricingInterval[]
}
+/**
+ * 多档时的计价基准:
+ * - whole_request:整单按所在档单价计价(目录阶梯、渠道区间);
+ * - marginal:仅超出阈值的部分按该档单价计价(平台旧规则)。
+ */
+export type PlazaLongContextBasis = 'whole_request' | 'marginal'
+
export interface PlazaModel {
name: string
platform: string
+ /** 实收口径的展示定价:多档时 intervals 为各档绝对单价(已由计费服务折算)。 */
pricing: UserSupportedModelPricing | null
official_pricing: PlazaOfficialPricing | null
+ /** 仅多档模型返回。 */
+ long_context_basis?: PlazaLongContextBasis
}
export interface ModelPlazaGroup {
@@ -43,6 +55,8 @@ export interface ModelPlazaGroup {
/** 生图独立倍率:true 时图片计费模型的实付倍率取 image_rate_multiplier,不取分组/专属倍率。 */
image_rate_independent: boolean
image_rate_multiplier: number
+ /** 分组是否启用长上下文阶梯计费;false 时实付列只展示最低档,官方阶梯仅供参考。 */
+ long_context_pricing_enabled: boolean
models: PlazaModel[]
}
diff --git a/frontend/src/components/modelPlaza/PlazaGroupSection.vue b/frontend/src/components/modelPlaza/PlazaGroupSection.vue
index 3a46c93aaf..38bdea058e 100644
--- a/frontend/src/components/modelPlaza/PlazaGroupSection.vue
+++ b/frontend/src/components/modelPlaza/PlazaGroupSection.vue
@@ -42,6 +42,13 @@
{{ peakNote }}
+
+
+ {{ longContextNote }}
+
@@ -92,4 +99,16 @@ const peakNote = computed(() => {
multiplier: props.group.peak_rate_multiplier
})
})
+
+/**
+ * 分组关闭了长上下文阶梯、但组内有模型官方带阶梯时提示:实付列只展示基础档,
+ * 官方阶梯仅供参考。字段缺失(旧后端)不提示。
+ */
+const longContextNote = computed(() => {
+ if (props.group.long_context_pricing_enabled !== false) return ''
+ const hasOfficialLadder = props.group.models.some(
+ (m) => (m.official_pricing?.intervals?.length ?? 0) > 1
+ )
+ return hasOfficialLadder ? t('modelPlaza.detail.longContextDisabledNote') : ''
+})
diff --git a/frontend/src/components/modelPlaza/PlazaModelPricingTable.vue b/frontend/src/components/modelPlaza/PlazaModelPricingTable.vue
index edc72365ff..96ad18df68 100644
--- a/frontend/src/components/modelPlaza/PlazaModelPricingTable.vue
+++ b/frontend/src/components/modelPlaza/PlazaModelPricingTable.vue
@@ -1,14 +1,14 @@
-
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
@@ -81,10 +81,17 @@
>
{{ billingModeLabel(m) }}
+
+ {{ t('modelPlaza.table.marginalBadge') }}
+
-
+
|
@@ -93,7 +100,7 @@
:key="idx"
class="whitespace-nowrap text-xs leading-5"
>
- {{ tierLabel(iv) }}
+ {{ tierLabel(iv) }}
{{ paidPerMillion(iv.input_price) }}
@@ -105,16 +112,32 @@
v-for="(iv, idx) in tokenIntervals(m)"
:key="idx"
class="whitespace-nowrap text-xs leading-5"
+ :title="tierHint(m)"
>
- {{ tierLabel(iv) }}
{{ paidPerMillion(iv.output_price) }}
{{ paidPerMillion(m.pricing?.output_price) }}
|
+
+
+
+ {{ t('modelPlaza.table.cacheWriteShort') }}
+ {{ paidPerMillion(iv.cache_write_price) }}
+ {{ t('modelPlaza.table.cacheReadShort') }}
+ {{ paidPerMillion(iv.cache_read_price) }}
+
+ -
+
+
|
-
+
- {{ official(m.official_pricing?.input_price) }}
+
+
+ {{ tierLabel(iv) }}
+ {{ official(iv.input_price) }}
+
+
+ {{ official(m.official_pricing?.input_price) }}
|
- {{ official(m.official_pricing?.output_price) }}
+
+
+ {{ official(iv.output_price) }}
+
+
+ {{ official(m.official_pricing?.output_price) }}
|
+
+
+
+ {{ t('modelPlaza.table.cacheWriteShort') }}
+ {{ official(iv.cache_write_price) }}
+ {{ t('modelPlaza.table.cacheReadShort') }}
+ {{ official(iv.cache_read_price) }}
+
+ -
+
+
@@ -322,23 +381,46 @@ function hasOfficialCache(o: NonNullable ): boole
return o.cache_write_price != null || o.cache_read_price != null || o.cache_write_1h_price != null
}
-/** token 模式的阶梯定价(内联进输入/输出列)。 */
+/** token 模式的阶梯定价(内联进输入/输出/缓存列)。 */
function tokenIntervals(m: PlazaModel): UserPricingInterval[] {
return m.pricing?.intervals ?? []
}
+/** 官方阶梯(后端按目录规则合成,不受分组开关影响)。 */
+function officialIntervals(m: PlazaModel): UserPricingInterval[] {
+ return m.official_pricing?.intervals ?? []
+}
+
+/** 任一档带缓存价才按档渲染缓存列;否则沿用平价的写入/读取两行。 */
+function hasTierCachePricing(intervals: UserPricingInterval[]): boolean {
+ return intervals.some((iv) => iv.cache_write_price != null || iv.cache_read_price != null)
+}
+
+/** 档位说明:整单按档计价,或(平台旧规则)仅超出部分按档计价。 */
+function tierHint(m: PlazaModel): string {
+ return m.long_context_basis === 'marginal'
+ ? t('modelPlaza.table.tierHintMarginal')
+ : t('modelPlaza.table.tierHint')
+}
+
+
/** 按次/按图模式的阶梯定价(仅保留配了按次价的档位)。 */
function requestIntervals(m: PlazaModel): UserPricingInterval[] {
return (m.pricing?.intervals ?? []).filter((iv) => iv.per_request_price != null)
}
-/** 档位标签:优先管理员配置的 tier_label,否则按 token 区间生成(≤200K / >200K / 200K–1M)。 */
+/** 档位标签:优先管理员配置的 tier_label,否则按 token 区间生成(≤200K / >200K / 100–200K / 200K–1M)。 */
function tierLabel(iv: UserPricingInterval): string {
if (iv.tier_label) return iv.tier_label
const { min_tokens: min, max_tokens: max } = iv
if (max == null) return `>${formatTokenCount(min)}`
if (min === 0) return `≤${formatTokenCount(max)}`
- return `${formatTokenCount(min)}–${formatTokenCount(max)}`
+ const lo = formatTokenCount(min)
+ const hi = formatTokenCount(max)
+ // 同单位时省略前一个单位(100–200K),节省列宽
+ const unit = hi.slice(-1)
+ if (/[KM]/.test(unit) && lo.endsWith(unit)) return `${lo.slice(0, -1)}–${hi}`
+ return `${lo}–${hi}`
}
function formatTokenCount(n: number): string {
diff --git a/frontend/src/components/modelPlaza/__tests__/PlazaGroupSection.spec.ts b/frontend/src/components/modelPlaza/__tests__/PlazaGroupSection.spec.ts
new file mode 100644
index 0000000000..ed30f10431
--- /dev/null
+++ b/frontend/src/components/modelPlaza/__tests__/PlazaGroupSection.spec.ts
@@ -0,0 +1,115 @@
+import { describe, expect, it, vi } from 'vitest'
+import { mount } from '@vue/test-utils'
+import PlazaGroupSection from '../PlazaGroupSection.vue'
+import type { ModelPlazaGroup, PlazaModel } from '@/api/modelPlaza'
+
+vi.mock('vue-i18n', async () => {
+ const actual = await vi.importActual('vue-i18n')
+ return {
+ ...actual,
+ useI18n: () => ({
+ t: (key: string) => key
+ })
+ }
+})
+
+vi.mock('@/stores/app', () => ({
+ useAppStore: () => ({ cachedPublicSettings: null })
+}))
+
+function ladderModel(tiers: number): PlazaModel {
+ const intervals = Array.from({ length: tiers }, (_, i) => ({
+ min_tokens: i * 272000,
+ max_tokens: i === tiers - 1 ? null : (i + 1) * 272000,
+ tier_label: '',
+ input_price: 5e-6,
+ output_price: 3e-5,
+ cache_write_price: null,
+ cache_read_price: null,
+ per_request_price: null
+ }))
+ return {
+ name: 'gpt-5.6-sol',
+ platform: 'openai',
+ pricing: {
+ billing_mode: 'token',
+ input_price: 5e-6,
+ output_price: 3e-5,
+ cache_write_price: null,
+ cache_read_price: null,
+ image_input_price: null,
+ image_output_price: null,
+ per_request_price: null,
+ intervals: []
+ },
+ official_pricing: {
+ input_price: 5e-6,
+ output_price: 3e-5,
+ cache_write_price: null,
+ cache_read_price: null,
+ intervals
+ }
+ }
+}
+
+function group(overrides: Partial = {}): ModelPlazaGroup {
+ return {
+ id: 1,
+ name: 'g',
+ description: '',
+ platform: 'openai',
+ subscription_type: 'standard',
+ rate_multiplier: 1,
+ peak_rate_enabled: false,
+ peak_start: '',
+ peak_end: '',
+ peak_rate_multiplier: 1,
+ is_exclusive: false,
+ image_rate_independent: false,
+ image_rate_multiplier: 1,
+ long_context_pricing_enabled: true,
+ models: [ladderModel(2)],
+ ...overrides
+ }
+}
+
+function mountSection(g: ModelPlazaGroup) {
+ return mount(PlazaGroupSection, {
+ props: { group: g },
+ global: {
+ stubs: {
+ GroupBadge: true,
+ Icon: true,
+ PlazaModelPricingTable: true
+ }
+ }
+ })
+}
+
+const NOTE = 'modelPlaza.detail.longContextDisabledNote'
+
+describe('PlazaGroupSection 长上下文说明', () => {
+ it('分组关闭阶梯且组内有官方阶梯模型时显示说明', () => {
+ const wrapper = mountSection(group({ long_context_pricing_enabled: false }))
+ expect(wrapper.text()).toContain(NOTE)
+ })
+
+ it('分组开启阶梯时不显示', () => {
+ const wrapper = mountSection(group({ long_context_pricing_enabled: true }))
+ expect(wrapper.text()).not.toContain(NOTE)
+ })
+
+ it('分组关闭但没有官方阶梯模型时不显示', () => {
+ const wrapper = mountSection(
+ group({ long_context_pricing_enabled: false, models: [ladderModel(1)] })
+ )
+ expect(wrapper.text()).not.toContain(NOTE)
+ })
+
+ it('旧后端缺少开关字段时不显示', () => {
+ const g = group()
+ delete (g as Partial).long_context_pricing_enabled
+ const wrapper = mountSection(g)
+ expect(wrapper.text()).not.toContain(NOTE)
+ })
+})
diff --git a/frontend/src/components/modelPlaza/__tests__/PlazaModelPricingTable.spec.ts b/frontend/src/components/modelPlaza/__tests__/PlazaModelPricingTable.spec.ts
index ca5f72cd6a..4ef79cce76 100644
--- a/frontend/src/components/modelPlaza/__tests__/PlazaModelPricingTable.spec.ts
+++ b/frontend/src/components/modelPlaza/__tests__/PlazaModelPricingTable.spec.ts
@@ -405,3 +405,133 @@ describe('PlazaModelPricingTable', () => {
expect(wrapper.text()).toContain('OpenAI')
})
})
+
+describe('PlazaModelPricingTable 长上下文阶梯', () => {
+ function ladderIntervals() {
+ return [
+ {
+ min_tokens: 0,
+ max_tokens: 272000,
+ tier_label: '≤272K',
+ input_price: 5e-6,
+ output_price: 3e-5,
+ cache_write_price: 6.25e-6,
+ cache_read_price: 5e-7,
+ per_request_price: null
+ },
+ {
+ min_tokens: 272000,
+ max_tokens: null,
+ tier_label: '>272K',
+ input_price: 1e-5,
+ output_price: 4.5e-5,
+ cache_write_price: 1.25e-5,
+ cache_read_price: 1e-6,
+ per_request_price: null
+ }
+ ]
+ }
+
+ function ladderModel(overrides: Partial = {}): PlazaModel {
+ return tokenModel({
+ name: 'gpt-5.6-sol',
+ platform: 'openai',
+ pricing: {
+ billing_mode: 'token',
+ input_price: 5e-6,
+ output_price: 3e-5,
+ cache_write_price: 6.25e-6,
+ cache_read_price: 5e-7,
+ image_input_price: null,
+ image_output_price: null,
+ per_request_price: null,
+ intervals: ladderIntervals()
+ },
+ official_pricing: {
+ input_price: 5e-6,
+ output_price: 3e-5,
+ cache_write_price: 6.25e-6,
+ cache_read_price: 5e-7,
+ intervals: ladderIntervals()
+ },
+ long_context_basis: 'whole_request',
+ ...overrides
+ })
+ }
+
+ it('实付缓存列按档分行并乘倍率,每档一行与输入/输出列对齐;档位标签只在输入列', () => {
+ const wrapper = mountTable([ladderModel()], 0.5)
+ const cells = wrapper.findAll('tbody td')
+ const cacheCell = cells[3]
+ const rows = cacheCell.findAll('.leading-5')
+ expect(rows).toHaveLength(2)
+ // 写 6.25 × 0.5 / 读 0.5 × 0.5;高档 12.5 × 0.5 / 1 × 0.5
+ expect(rows[0].text()).toContain('modelPlaza.table.cacheWriteShort')
+ expect(rows[0].text()).toContain('$3.125')
+ expect(rows[0].text()).toContain('$0.25')
+ expect(rows[1].text()).toContain('$6.25')
+ expect(rows[1].text()).toContain('$0.50')
+ // 输入列带标签,输出/缓存列只按行对齐不重复标签
+ expect(cells[1].text()).toContain('≤272K')
+ expect(cells[1].text()).toContain('>272K')
+ expect(cells[2].text()).not.toContain('272K')
+ expect(cacheCell.text()).not.toContain('272K')
+ expect(cells[1].findAll('.leading-5')).toHaveLength(2)
+ expect(cells[2].findAll('.leading-5')).toHaveLength(2)
+ })
+
+ it('官方三列按 official_pricing.intervals 分档且不乘倍率,不内联 1h', () => {
+ const wrapper = mountTable([ladderModel()], 0.5)
+ const cells = wrapper.findAll('tbody td')
+ expect(cells[4].text()).toContain('≤272K')
+ expect(cells[4].text()).toContain('$5.00')
+ expect(cells[4].text()).toContain('>272K')
+ expect(cells[4].text()).toContain('$10.00')
+ expect(cells[5].text()).toContain('$30.00')
+ expect(cells[5].text()).toContain('$45.00')
+ expect(cells[6].text()).toContain('$6.25')
+ expect(cells[6].text()).toContain('$12.50')
+ expect(cells[6].text()).toContain('$1.00')
+ expect(cells[6].text()).not.toContain('(1h')
+ })
+
+ it('整单计价的档位标签带 tooltip;边际计价在模型名旁加徽章并换用边际说明', () => {
+ const whole = mountTable([ladderModel()], 1)
+ const wholeLabels = whole.findAll('tbody td span[title="modelPlaza.table.tierHint"]')
+ expect(wholeLabels.length).toBeGreaterThan(0)
+ expect(whole.text()).not.toContain('modelPlaza.table.marginalBadge')
+
+ const marginal = mountTable([ladderModel({ long_context_basis: 'marginal' })], 1)
+ const marginalLabels = marginal.findAll('tbody td span[title="modelPlaza.table.tierHintMarginal"]')
+ expect(marginalLabels.length).toBeGreaterThan(0)
+ expect(marginal.findAll('tbody td')[0].text()).toContain('modelPlaza.table.marginalBadge')
+ })
+
+ it('自定义中间档标签同单位时省略前一个单位', () => {
+ const model = ladderModel({
+ pricing: {
+ ...ladderModel().pricing!,
+ intervals: [
+ { ...ladderIntervals()[0], max_tokens: 100000, tier_label: '' },
+ { ...ladderIntervals()[0], min_tokens: 100000, max_tokens: 200000, tier_label: '' },
+ { ...ladderIntervals()[1], min_tokens: 200000, max_tokens: 1000000, tier_label: '' },
+ { ...ladderIntervals()[1], min_tokens: 1000000, tier_label: '' }
+ ]
+ }
+ })
+ const text = mountTable([model], 1).findAll('tbody td')[1].text()
+ expect(text).toContain('≤100K')
+ expect(text).toContain('100–200K')
+ expect(text).toContain('200K–1M')
+ expect(text).toContain('>1M')
+ })
+
+ it('官方无 intervals 字段(旧响应)时官方列保持平价,实付无阶梯时缓存列保持两行', () => {
+ const wrapper = mountTable([tokenModel()], 1)
+ const cells = wrapper.findAll('tbody td')
+ expect(cells[3].text()).toContain('modelPlaza.table.cacheWrite')
+ expect(cells[3].text()).toContain('modelPlaza.table.cacheRead')
+ expect(cells[3].findAll('.leading-5')).toHaveLength(0)
+ expect(cells[6].text()).toContain('(1h')
+ })
+})
diff --git a/frontend/src/i18n/locales/en/dashboard.ts b/frontend/src/i18n/locales/en/dashboard.ts
index 9f49522a84..9abea14e19 100644
--- a/frontend/src/i18n/locales/en/dashboard.ts
+++ b/frontend/src/i18n/locales/en/dashboard.ts
@@ -589,7 +589,8 @@ export default {
detail: {
noModels: 'No models configured for this group',
noPricing: 'Pricing not configured',
- peakNote: 'Peak hours {window}: billing rate ×{multiplier}'
+ peakNote: 'Peak hours {window}: billing rate ×{multiplier}',
+ longContextDisabledNote: 'Long-context tier pricing is disabled for this group: requests above the threshold are billed at the base tier; official tiers are for reference only'
},
table: {
model: 'Model',
@@ -598,6 +599,11 @@ export default {
cache: 'Cache',
cacheWrite: 'Write',
cacheRead: 'Read',
+ cacheWriteShort: 'W',
+ cacheReadShort: 'R',
+ tierHint: 'The whole request is billed at the tier matching its total context (input + cache write + cache read)',
+ tierHintMarginal: 'Only the portion above the threshold is billed at this tier; output is unaffected',
+ marginalBadge: 'excess-only tiers',
paidPrice: 'Your Price (Discounted)',
officialPrice: 'Official Price',
rate: 'Rate',
diff --git a/frontend/src/i18n/locales/zh/dashboard.ts b/frontend/src/i18n/locales/zh/dashboard.ts
index bcd31fa1f5..b80a18170f 100644
--- a/frontend/src/i18n/locales/zh/dashboard.ts
+++ b/frontend/src/i18n/locales/zh/dashboard.ts
@@ -594,7 +594,8 @@ export default {
detail: {
noModels: '该分组暂未配置模型',
noPricing: '未配置定价',
- peakNote: '高峰时段 {window} 计费倍率 ×{multiplier}'
+ peakNote: '高峰时段 {window} 计费倍率 ×{multiplier}',
+ longContextDisabledNote: '该分组未启用长上下文阶梯计费,超阈值请求仍按基础档计费,官方阶梯仅供参考'
},
table: {
model: '模型',
@@ -603,6 +604,11 @@ export default {
cache: '缓存',
cacheWrite: '写入',
cacheRead: '读取',
+ cacheWriteShort: '写',
+ cacheReadShort: '读',
+ tierHint: '按单次请求的总上下文(输入 + 缓存写入 + 缓存读取)所在档位对整单计价',
+ tierHintMarginal: '仅超过阈值的部分按该档计价,输出不加价',
+ marginalBadge: '超出部分计价',
paidPrice: '实付价格(折后)',
officialPrice: '官方价格',
rate: '折扣倍率',
|