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 1300989dc..a51e13bb9 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 300eb1b1a..a6b7a36a3 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 0bf294d44..4ea24a6a2 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 a7fc291ab..97fee63b7 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 eeaf7dac2..6face6e1d 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 d59e587ec..2b7b23f7e 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 1e4dee271..000000000 --- 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 000000000..10a8eda55 --- /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 554654149..df4a20cdf 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 3e469a95c..17a68799c 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 c17b3aa1a..f881eab70 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 3a46c93aa..38bdea058 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 edc72365f..96ad18df6 100644 --- a/frontend/src/components/modelPlaza/PlazaModelPricingTable.vue +++ b/frontend/src/components/modelPlaza/PlazaModelPricingTable.vue @@ -1,14 +1,14 @@ - + - {{ official(m.official_pricing?.input_price) }} + + - {{ official(m.official_pricing?.output_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 000000000..ed30f1043 --- /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 ca5f72cd6..4ef79cce7 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 9f49522a8..9abea14e1 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 bcd31fa1f..b80a18170 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: '折扣倍率',