Files
sub2api/backend/internal/service/model_plaza_service.go
T
feeeei 9a51333101 feat(groups): 分组名称与描述支持多语言
- groups 新增 i18n JSONB 列(迁移 242):语言代码 -> {name, description},只存译文;
  name/description 原列语义不变,仍用于唯一性校验、搜索与各类日志
- 用户侧接口由后端按请求语言输出:取 Accept-Language 中权重最高的一项,先按完整
  语言代码、再按主语言匹配;该语言有译文的字段换成译文,否则保持原字段,且不再带出
  译文表。覆盖密钥、可用分组、订阅(含进度与汇总)、兑换、使用记录、可用渠道、
  模型广场、套餐列表
- 管理端接口一律返回原字段;分组管理接口额外带 i18n 供编辑
- 创建/编辑分组:名称、描述的标题行右侧新增语言下拉,同一个输入框在默认内容与各语言
  版本之间切换;语种跟随前端 availableLocales
- 管理端写入:创建/更新接受 i18n(更新时缺省不修改,传入则整体替换),校验语言代码
  形态与名称长度并丢弃空译文;简易模式同样放行;复制分组只带走描述译文
- 切换界面语言后整页刷新,让已加载的数据换成新语言;模型广场响应加
  Vary: Accept-Language
- 网关路径,以及只带 group_name 字符串的统计、错误记录出口不做本地化
2026-10-09 11:39:18 +08:00

378 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
// TimePricing 计费会生效的分时倍率时段;无分时为 nil。
TimePricing *TimePricingSchedule
}
// PlazaGroup 模型广场中以分组为顶层的条目。
//
// 与 AvailableGroupRef 相比多了 Description 与 Models;Models 来自该分组关联渠道的
// 支持模型(普通分组按分组平台隔离,Composite 分组展开关联渠道已配置的
// 具体平台),与「可用渠道」页口径一致。
type PlazaGroup struct {
ID int64
Name string
Description string
I18n GroupI18n
Platform string
SubscriptionType string
RateMultiplier float64
PeakRateEnabled bool
PeakStart string
PeakEnd string
PeakRateMultiplier float64
IsExclusive bool
// 图片按次实付倍率:ImageRateIndependent 为 true 时,图片计费模型的实付
// = 档位价 × ImageRateMultiplier,不乘分组/用户专属倍率(与计费口径一致)。
ImageRateIndependent bool
ImageRateMultiplier float64
// 视频独立倍率与图片独立倍率分别配置,开启时覆盖分组/用户专属倍率。
VideoRateIndependent bool
VideoRateMultiplier 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,
I18n: g.I18n,
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,
VideoRateIndependent: g.VideoRateIndependent,
VideoRateMultiplier: g.VideoRateMultiplier,
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 groupPricing := matchGroupModelPricing(g, m.Name); groupPricing != nil {
m.Pricing = groupPricing
}
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
}
m.TimePricing = sched.TimePricing
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
out.ReasoningEffortMultipliers = reasoningEffortMultipliersFromPricing(raw)
}
first := sched.Tiers[0]
out.InputPrice = first.Input
out.OutputPrice = first.Output
out.CacheWritePrice = first.CacheWrite
out.CacheWrite1hPrice = first.CacheWrite1h
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,
CacheWrite1hPrice: t.CacheWrite1h,
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
}