refactor(channels): consolidate pricing index, tighten types, polish DTOs
Follow-up to the available-channels review pass. No behavior change for end users; tightens internals based on three independent code reviews. Backend - service/channel.go: collapse buildPricingLookup + pricedNamesFor into a single platformPricingIndex (byLower + originalCase + ordered names), built once per SupportedModels call. Fixes a casing- consistency bug where the same logical model appeared with mapping case in the exact branch but pricing case in the wildcard branch — pricing's original case now wins everywhere. - service/channel.go: doc that a mapping key of just "*" expands to every priced model on the platform (intentional "passthrough all"). - service/channel_available.go: normalize empty BillingModelSource to channel_mapped at construction time, removing the same fallback duplicated in the admin DTO mapper and the admin Vue template. - handler/admin/available_channel_handler.go: unexport availableChannelToAdminResponse (same-package usage only); mapper is now a pure passthrough. - handler/available_channel_handler.go: drop the middleware2 alias (no name collision in this file). Frontend - utils/pricing.ts: extract formatScaled, used by SupportedModelChip and PricingRow. - api/admin/channels.ts: re-export BillingMode from constants/channel; tighten Channel.status / billing_model_source to ChannelStatus / BillingModelSource (and same for AvailableChannel). - components/channels/AvailableChannelsTable.vue: drop dead withDefaults wrapper (loading is required, both call sites pass it). - views/admin/AvailableChannelsView.vue: drop the redundant || BILLING_MODEL_SOURCE_CHANNEL_MAPPED fallback (now applied in service layer); remove unused import. - i18n zh + en: delete unused tierLabel and tokenRange keys from both availableChannels.pricing and admin.availableChannels.pricing. Tests - New: SupportedModels_ExactKeyUsesPricedCaseWhenAvailable locks the pricing-case-wins rule. - New: SupportedModels_AsteriskOnlyMappingExpandsAllPriced documents the "*" expansion rule. - Admin handler: existing tests adjusted to pass an explicit BillingModelSource (default-fill is now exercised by service tests).
This commit is contained in:
@@ -45,9 +45,9 @@ type availableChannelResponse struct {
|
|||||||
SupportedModels []supportedModelResponse `json:"supported_models"`
|
SupportedModels []supportedModelResponse `json:"supported_models"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AvailableChannelToAdminResponse 将 service 层的 AvailableChannel 转为管理员 DTO。
|
// availableChannelToAdminResponse 将 service 层的 AvailableChannel 转为管理员 DTO。
|
||||||
// 导出供同 package 的复用;也用于构造测试 fixture。
|
// 同 package 内复用;也用于构造测试 fixture。
|
||||||
func AvailableChannelToAdminResponse(ch service.AvailableChannel) availableChannelResponse {
|
func availableChannelToAdminResponse(ch service.AvailableChannel) availableChannelResponse {
|
||||||
groups := make([]availableGroupResponse, 0, len(ch.Groups))
|
groups := make([]availableGroupResponse, 0, len(ch.Groups))
|
||||||
for _, g := range ch.Groups {
|
for _, g := range ch.Groups {
|
||||||
groups = append(groups, availableGroupResponse{ID: g.ID, Name: g.Name, Platform: g.Platform})
|
groups = append(groups, availableGroupResponse{ID: g.ID, Name: g.Name, Platform: g.Platform})
|
||||||
@@ -66,16 +66,12 @@ func AvailableChannelToAdminResponse(ch service.AvailableChannel) availableChann
|
|||||||
Pricing: pricing,
|
Pricing: pricing,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
billingSource := ch.BillingModelSource
|
|
||||||
if billingSource == "" {
|
|
||||||
billingSource = service.BillingModelSourceChannelMapped
|
|
||||||
}
|
|
||||||
return availableChannelResponse{
|
return availableChannelResponse{
|
||||||
ID: ch.ID,
|
ID: ch.ID,
|
||||||
Name: ch.Name,
|
Name: ch.Name,
|
||||||
Description: ch.Description,
|
Description: ch.Description,
|
||||||
Status: ch.Status,
|
Status: ch.Status,
|
||||||
BillingModelSource: billingSource,
|
BillingModelSource: ch.BillingModelSource,
|
||||||
RestrictModels: ch.RestrictModels,
|
RestrictModels: ch.RestrictModels,
|
||||||
Groups: groups,
|
Groups: groups,
|
||||||
SupportedModels: models,
|
SupportedModels: models,
|
||||||
@@ -93,7 +89,7 @@ func (h *AvailableChannelHandler) List(c *gin.Context) {
|
|||||||
|
|
||||||
out := make([]availableChannelResponse, 0, len(channels))
|
out := make([]availableChannelResponse, 0, len(channels))
|
||||||
for _, ch := range channels {
|
for _, ch := range channels {
|
||||||
out = append(out, AvailableChannelToAdminResponse(ch))
|
out = append(out, availableChannelToAdminResponse(ch))
|
||||||
}
|
}
|
||||||
response.Success(c, gin.H{"items": out})
|
response.Success(c, gin.H{"items": out})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,13 +12,13 @@ import (
|
|||||||
|
|
||||||
func TestAvailableChannelToAdminResponse_IncludesFullDTO(t *testing.T) {
|
func TestAvailableChannelToAdminResponse_IncludesFullDTO(t *testing.T) {
|
||||||
// 管理员视图应包含 id / status / billing_model_source / restrict_models 等
|
// 管理员视图应包含 id / status / billing_model_source / restrict_models 等
|
||||||
// 管理字段;BillingModelSource 为空时应默认回填 channel_mapped。
|
// 管理字段;mapper 是纯透传,BillingModelSource 的默认回填由 service 层负责。
|
||||||
input := service.AvailableChannel{
|
input := service.AvailableChannel{
|
||||||
ID: 42,
|
ID: 42,
|
||||||
Name: "ch",
|
Name: "ch",
|
||||||
Description: "d",
|
Description: "d",
|
||||||
Status: service.StatusActive,
|
Status: service.StatusActive,
|
||||||
BillingModelSource: "", // 验证默认值填充
|
BillingModelSource: service.BillingModelSourceChannelMapped,
|
||||||
RestrictModels: true,
|
RestrictModels: true,
|
||||||
Groups: []service.AvailableGroupRef{
|
Groups: []service.AvailableGroupRef{
|
||||||
{ID: 1, Name: "g1", Platform: "anthropic"},
|
{ID: 1, Name: "g1", Platform: "anthropic"},
|
||||||
@@ -28,7 +28,7 @@ func TestAvailableChannelToAdminResponse_IncludesFullDTO(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
resp := AvailableChannelToAdminResponse(input)
|
resp := availableChannelToAdminResponse(input)
|
||||||
require.Equal(t, int64(42), resp.ID)
|
require.Equal(t, int64(42), resp.ID)
|
||||||
require.Equal(t, "ch", resp.Name)
|
require.Equal(t, "ch", resp.Name)
|
||||||
require.Equal(t, service.StatusActive, resp.Status)
|
require.Equal(t, service.StatusActive, resp.Status)
|
||||||
@@ -52,6 +52,6 @@ func TestAvailableChannelToAdminResponse_PreservesExplicitBillingSource(t *testi
|
|||||||
input := service.AvailableChannel{
|
input := service.AvailableChannel{
|
||||||
BillingModelSource: service.BillingModelSourceUpstream,
|
BillingModelSource: service.BillingModelSourceUpstream,
|
||||||
}
|
}
|
||||||
resp := AvailableChannelToAdminResponse(input)
|
resp := availableChannelToAdminResponse(input)
|
||||||
require.Equal(t, service.BillingModelSourceUpstream, resp.BillingModelSource)
|
require.Equal(t, service.BillingModelSourceUpstream, resp.BillingModelSource)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||||
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -83,7 +83,7 @@ type userAvailableChannel struct {
|
|||||||
// List 列出当前用户可见的「可用渠道」。
|
// List 列出当前用户可见的「可用渠道」。
|
||||||
// GET /api/v1/channels/available
|
// GET /api/v1/channels/available
|
||||||
func (h *AvailableChannelHandler) List(c *gin.Context) {
|
func (h *AvailableChannelHandler) List(c *gin.Context) {
|
||||||
subject, ok := middleware2.GetAuthSubjectFromContext(c)
|
subject, ok := middleware.GetAuthSubjectFromContext(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
response.Unauthorized(c, "User not authenticated")
|
response.Unauthorized(c, "User not authenticated")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -391,59 +391,50 @@ func (c *Channel) GetModelPricingByPlatform(platform, model string) *ChannelMode
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// pricingLookup 是渠道定价在单个计算过程中的索引:platform → (lowerName → *pricing)。
|
// platformPricingIndex 是单个平台下定价信息的复合索引。
|
||||||
// 用于将 SupportedModels 的定价解析从 O(N*M) 降到 O(N+M)。
|
// 一次扫描即可同时支持精确查找(exact 分支)与有序遍历(wildcard 分支),
|
||||||
type pricingLookup map[string]map[string]*ChannelModelPricing
|
// 避免 SupportedModels 对每个平台重复扫描定价列表。
|
||||||
|
//
|
||||||
|
// byLower 与 names/originalCase 共享同一套去重规则:以 lower-case 模型名为 key,
|
||||||
|
// 首个命中保留其原始大小写。names 维持按定价行扫描顺序的稳定迭代。
|
||||||
|
type platformPricingIndex struct {
|
||||||
|
byLower map[string]*ChannelModelPricing // lowercased model name → pricing (Clone'd)
|
||||||
|
originalCase map[string]string // lowercased model name → original-case model name
|
||||||
|
names []string // priced model names in their ORIGINAL case, insertion-ordered, deduped case-insensitively (first wins)
|
||||||
|
}
|
||||||
|
|
||||||
// buildPricingLookup 对渠道的定价列表做一次扫描,生成 platform+模型名 的索引。
|
// buildPricingIndex 对渠道的定价列表做一次扫描,按 platform 聚合为查找索引。
|
||||||
// 索引值是定价条目的 Clone 指针,调用方可安全按需返回副本而不污染缓存。
|
// 索引值是定价条目的 Clone 指针,调用方可安全按需返回副本而不污染缓存。
|
||||||
// wildcard 后缀(如 "claude-*")不会被索引(它们不是精确模型名)。
|
// 通配符后缀条目(如 "claude-*")不被索引(它们是模式,不是具体模型名)。
|
||||||
func buildPricingLookup(pricings []ChannelModelPricing) pricingLookup {
|
// 同一平台中以大小写不敏感方式去重,先出现者保留原始大小写。
|
||||||
lookup := make(pricingLookup, len(pricings))
|
func buildPricingIndex(pricings []ChannelModelPricing) map[string]*platformPricingIndex {
|
||||||
|
idx := make(map[string]*platformPricingIndex)
|
||||||
for i := range pricings {
|
for i := range pricings {
|
||||||
p := pricings[i]
|
p := pricings[i]
|
||||||
byModel, ok := lookup[p.Platform]
|
pidx, ok := idx[p.Platform]
|
||||||
if !ok {
|
if !ok {
|
||||||
byModel = make(map[string]*ChannelModelPricing, len(p.Models))
|
pidx = &platformPricingIndex{
|
||||||
lookup[p.Platform] = byModel
|
byLower: make(map[string]*ChannelModelPricing),
|
||||||
|
originalCase: make(map[string]string),
|
||||||
|
names: make([]string, 0),
|
||||||
|
}
|
||||||
|
idx[p.Platform] = pidx
|
||||||
}
|
}
|
||||||
for _, m := range p.Models {
|
for _, m := range p.Models {
|
||||||
if _, wild := splitWildcardSuffix(m); wild {
|
if _, wild := splitWildcardSuffix(m); wild {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
lower := strings.ToLower(m)
|
lower := strings.ToLower(m)
|
||||||
if _, exists := byModel[lower]; exists {
|
if _, exists := pidx.byLower[lower]; exists {
|
||||||
continue // 首个命中胜出(保持 case-insensitive 去重后第一个定价)
|
continue // 首个命中胜出(case-insensitive 去重后第一个定价 / 第一个原始大小写)
|
||||||
}
|
}
|
||||||
cp := pricings[i].Clone()
|
cp := pricings[i].Clone()
|
||||||
byModel[lower] = &cp
|
pidx.byLower[lower] = &cp
|
||||||
|
pidx.originalCase[lower] = m
|
||||||
|
pidx.names = append(pidx.names, m)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return lookup
|
return idx
|
||||||
}
|
|
||||||
|
|
||||||
// pricedNamesFor 返回指定平台下已索引的精确模型名(保留原始大小写,按添加顺序)。
|
|
||||||
// 它是从 pricingLookup 中取 keys 并回查原始 ModelPricing 以得到原样字符串。
|
|
||||||
func pricedNamesFor(pricings []ChannelModelPricing, platform string) []string {
|
|
||||||
seen := make(map[string]struct{})
|
|
||||||
out := make([]string, 0)
|
|
||||||
for i := range pricings {
|
|
||||||
if pricings[i].Platform != platform {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, m := range pricings[i].Models {
|
|
||||||
if _, wild := splitWildcardSuffix(m); wild {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
lower := strings.ToLower(m)
|
|
||||||
if _, ok := seen[lower]; ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[lower] = struct{}{}
|
|
||||||
out = append(out, m)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SupportedModels 计算渠道的支持模型列表,结果保证不含通配符。
|
// SupportedModels 计算渠道的支持模型列表,结果保证不含通配符。
|
||||||
@@ -452,16 +443,19 @@ func pricedNamesFor(pricings []ChannelModelPricing, platform string) []string {
|
|||||||
// - 遍历 Channel.ModelMapping 的每个 platform 条目;
|
// - 遍历 Channel.ModelMapping 的每个 platform 条目;
|
||||||
// - 映射 key 不带尾部 "*":直接作为一个支持模型名(即使没有匹配的定价行,也会产出 Pricing=nil 的条目);
|
// - 映射 key 不带尾部 "*":直接作为一个支持模型名(即使没有匹配的定价行,也会产出 Pricing=nil 的条目);
|
||||||
// - 映射 key 带尾部 "*":用同 platform 的 ModelPricing.Models 做前缀匹配展开(定价中带 "*" 的条目被忽略,因为它们本身就是模式,不是具体模型名);
|
// - 映射 key 带尾部 "*":用同 platform 的 ModelPricing.Models 做前缀匹配展开(定价中带 "*" 的条目被忽略,因为它们本身就是模式,不是具体模型名);
|
||||||
|
// - 映射 key 为 `"*"`(单独一个星号)将展开为该平台所有定价模型(前缀为空 → 全匹配)。这是刻意行为,用于"将该平台所有模型透传"的场景;
|
||||||
// - 未在 ModelMapping 中出现的 platform 不会产出任何条目——这是**刻意设计**("没配映射就不显示"),即使该平台有定价行。
|
// - 未在 ModelMapping 中出现的 platform 不会产出任何条目——这是**刻意设计**("没配映射就不显示"),即使该平台有定价行。
|
||||||
//
|
//
|
||||||
// 每个结果尝试从 pricingLookup(平台+模型名索引)查找精确定价,未配置则 Pricing=nil。
|
// 当映射 key(exact 或 wildcard 展开后的候选)能命中定价时,结果中的 Name 使用**定价的原始大小写**
|
||||||
|
// (定价是模型身份的事实来源),否则保留映射 key 的原始大小写。
|
||||||
|
// 每个结果尝试从 platform 索引查找精确定价,未配置则 Pricing=nil。
|
||||||
// 结果按 (Platform, Name) 稳定排序,并按 (Platform, lowercase(Name)) 去重。
|
// 结果按 (Platform, Name) 稳定排序,并按 (Platform, lowercase(Name)) 去重。
|
||||||
func (c *Channel) SupportedModels() []SupportedModel {
|
func (c *Channel) SupportedModels() []SupportedModel {
|
||||||
if c == nil || len(c.ModelMapping) == 0 {
|
if c == nil || len(c.ModelMapping) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
lookup := buildPricingLookup(c.ModelPricing)
|
idx := buildPricingIndex(c.ModelPricing)
|
||||||
|
|
||||||
type dedupKey struct {
|
type dedupKey struct {
|
||||||
platform string
|
platform string
|
||||||
@@ -470,20 +464,23 @@ func (c *Channel) SupportedModels() []SupportedModel {
|
|||||||
seen := make(map[dedupKey]struct{})
|
seen := make(map[dedupKey]struct{})
|
||||||
result := make([]SupportedModel, 0)
|
result := make([]SupportedModel, 0)
|
||||||
|
|
||||||
add := func(platform, name string) {
|
add := func(platform, name string, pidx *platformPricingIndex) {
|
||||||
key := dedupKey{platform: platform, name: strings.ToLower(name)}
|
key := dedupKey{platform: platform, name: strings.ToLower(name)}
|
||||||
if _, ok := seen[key]; ok {
|
if _, ok := seen[key]; ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
seen[key] = struct{}{}
|
seen[key] = struct{}{}
|
||||||
var pricing *ChannelModelPricing
|
var pricing *ChannelModelPricing
|
||||||
if byModel, ok := lookup[platform]; ok {
|
displayName := name
|
||||||
if p, ok := byModel[strings.ToLower(name)]; ok {
|
if pidx != nil {
|
||||||
|
lower := strings.ToLower(name)
|
||||||
|
if p, ok := pidx.byLower[lower]; ok {
|
||||||
pricing = p
|
pricing = p
|
||||||
|
displayName = pidx.originalCase[lower] // 定价大小写胜出
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result = append(result, SupportedModel{
|
result = append(result, SupportedModel{
|
||||||
Name: name,
|
Name: displayName,
|
||||||
Platform: platform,
|
Platform: platform,
|
||||||
Pricing: pricing,
|
Pricing: pricing,
|
||||||
})
|
})
|
||||||
@@ -493,19 +490,22 @@ func (c *Channel) SupportedModels() []SupportedModel {
|
|||||||
if len(mapping) == 0 {
|
if len(mapping) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
pricedNames := pricedNamesFor(c.ModelPricing, platform)
|
pidx := idx[platform] // 可能为 nil(该平台无定价行)
|
||||||
for src := range mapping {
|
for src := range mapping {
|
||||||
prefix, isWild := splitWildcardSuffix(src)
|
prefix, isWild := splitWildcardSuffix(src)
|
||||||
if isWild {
|
if isWild {
|
||||||
|
if pidx == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
prefixLower := strings.ToLower(prefix)
|
prefixLower := strings.ToLower(prefix)
|
||||||
for _, candidate := range pricedNames {
|
for _, candidate := range pidx.names {
|
||||||
if strings.HasPrefix(strings.ToLower(candidate), prefixLower) {
|
if strings.HasPrefix(strings.ToLower(candidate), prefixLower) {
|
||||||
add(platform, candidate)
|
add(platform, candidate, pidx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
add(platform, src)
|
add(platform, src, pidx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,12 +65,17 @@ func (s *ChannelService) ListAvailable(ctx context.Context) ([]AvailableChannel,
|
|||||||
}
|
}
|
||||||
sort.Slice(groups, func(i, j int) bool { return groups[i].Name < groups[j].Name })
|
sort.Slice(groups, func(i, j int) bool { return groups[i].Name < groups[j].Name })
|
||||||
|
|
||||||
|
billingSource := ch.BillingModelSource
|
||||||
|
if billingSource == "" {
|
||||||
|
billingSource = BillingModelSourceChannelMapped
|
||||||
|
}
|
||||||
|
|
||||||
out = append(out, AvailableChannel{
|
out = append(out, AvailableChannel{
|
||||||
ID: ch.ID,
|
ID: ch.ID,
|
||||||
Name: ch.Name,
|
Name: ch.Name,
|
||||||
Description: ch.Description,
|
Description: ch.Description,
|
||||||
Status: ch.Status,
|
Status: ch.Status,
|
||||||
BillingModelSource: ch.BillingModelSource,
|
BillingModelSource: billingSource,
|
||||||
RestrictModels: ch.RestrictModels,
|
RestrictModels: ch.RestrictModels,
|
||||||
Groups: groups,
|
Groups: groups,
|
||||||
SupportedModels: ch.SupportedModels(),
|
SupportedModels: ch.SupportedModels(),
|
||||||
|
|||||||
@@ -637,3 +637,34 @@ func TestSupportedModels_EmptyPlatformMapping(t *testing.T) {
|
|||||||
}
|
}
|
||||||
require.Empty(t, ch.SupportedModels())
|
require.Empty(t, ch.SupportedModels())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSupportedModels_ExactKeyUsesPricedCaseWhenAvailable(t *testing.T) {
|
||||||
|
// mapping key uses uppercase, pricing uses lowercase — pricing's case should win.
|
||||||
|
ch := &Channel{
|
||||||
|
ModelPricing: []ChannelModelPricing{
|
||||||
|
{ID: 1, Platform: "openai", Models: []string{"gpt-4o"}},
|
||||||
|
},
|
||||||
|
ModelMapping: map[string]map[string]string{
|
||||||
|
"openai": {"GPT-4o": "gpt-4o"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
got := ch.SupportedModels()
|
||||||
|
require.Len(t, got, 1)
|
||||||
|
require.Equal(t, "gpt-4o", got[0].Name) // pricing's case wins
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSupportedModels_AsteriskOnlyMappingExpandsAllPriced(t *testing.T) {
|
||||||
|
// 映射 key 为单独的 "*":前缀为空 → 命中该平台所有定价模型(透传场景)。
|
||||||
|
ch := &Channel{
|
||||||
|
ModelPricing: []ChannelModelPricing{
|
||||||
|
{ID: 1, Platform: "openai", Models: []string{"gpt-4o", "gpt-4o-mini"}},
|
||||||
|
},
|
||||||
|
ModelMapping: map[string]map[string]string{
|
||||||
|
"openai": {"*": "gpt-4o"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
got := ch.SupportedModels()
|
||||||
|
require.Len(t, got, 2)
|
||||||
|
names := []string{got[0].Name, got[1].Name}
|
||||||
|
require.ElementsMatch(t, []string{"gpt-4o", "gpt-4o-mini"}, names)
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,8 +4,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { apiClient } from '../client'
|
import { apiClient } from '../client'
|
||||||
|
import type { BillingMode, ChannelStatus, BillingModelSource } from '@/constants/channel'
|
||||||
|
|
||||||
export type BillingMode = 'token' | 'per_request' | 'image'
|
export type { BillingMode } from '@/constants/channel'
|
||||||
|
|
||||||
export interface PricingInterval {
|
export interface PricingInterval {
|
||||||
id?: number
|
id?: number
|
||||||
@@ -46,8 +47,8 @@ export interface Channel {
|
|||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
status: string
|
status: ChannelStatus
|
||||||
billing_model_source: string // "requested" | "upstream"
|
billing_model_source: BillingModelSource
|
||||||
restrict_models: boolean
|
restrict_models: boolean
|
||||||
features_config?: Record<string, unknown>
|
features_config?: Record<string, unknown>
|
||||||
group_ids: number[]
|
group_ids: number[]
|
||||||
@@ -181,8 +182,8 @@ export interface AvailableChannel {
|
|||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
status: string
|
status: ChannelStatus
|
||||||
billing_model_source: string
|
billing_model_source: BillingModelSource
|
||||||
restrict_models: boolean
|
restrict_models: boolean
|
||||||
groups: AvailableGroupRef[]
|
groups: AvailableGroupRef[]
|
||||||
supported_models: SupportedModel[]
|
supported_models: SupportedModel[]
|
||||||
|
|||||||
@@ -85,7 +85,6 @@ interface Column {
|
|||||||
label: string
|
label: string
|
||||||
}
|
}
|
||||||
|
|
||||||
withDefaults(
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
columns: Column[]
|
columns: Column[]
|
||||||
rows: Row[]
|
rows: Row[]
|
||||||
@@ -94,9 +93,7 @@ withDefaults(
|
|||||||
noPricingLabel: string
|
noPricingLabel: string
|
||||||
noModelsLabel: string
|
noModelsLabel: string
|
||||||
emptyLabel: string
|
emptyLabel: string
|
||||||
}>(),
|
}>()
|
||||||
{ loading: false }
|
|
||||||
)
|
|
||||||
|
|
||||||
const slots = useSlots()
|
const slots = useSlots()
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import { formatScaled } from '@/utils/pricing'
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -18,11 +19,6 @@ const props = withDefaults(
|
|||||||
{ value: null }
|
{ value: null }
|
||||||
)
|
)
|
||||||
|
|
||||||
function formatScaled(value: number | null, scale: number): string {
|
|
||||||
if (value == null) return '-'
|
|
||||||
return `$${(value * scale).toPrecision(10).replace(/\.?0+$/, '')}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const display = computed(() =>
|
const display = computed(() =>
|
||||||
props.value == null ? '-' : `${formatScaled(props.value, props.scale)} ${props.unit}`
|
props.value == null ? '-' : `${formatScaled(props.value, props.scale)} ${props.unit}`
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -120,6 +120,7 @@
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import PricingRow from './PricingRow.vue'
|
import PricingRow from './PricingRow.vue'
|
||||||
|
import { formatScaled } from '@/utils/pricing'
|
||||||
import {
|
import {
|
||||||
BILLING_MODE_TOKEN,
|
BILLING_MODE_TOKEN,
|
||||||
BILLING_MODE_PER_REQUEST,
|
BILLING_MODE_PER_REQUEST,
|
||||||
@@ -193,11 +194,6 @@ const billingModeLabel = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function formatScaled(value: number | null | undefined, scale: number): string {
|
|
||||||
if (value == null) return '-'
|
|
||||||
return `$${(value * scale).toPrecision(10).replace(/\.?0+$/, '')}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatRange(min: number, max: number | null): string {
|
function formatRange(min: number, max: number | null): string {
|
||||||
const maxLabel = max == null ? '∞' : String(max)
|
const maxLabel = max == null ? '∞' : String(max)
|
||||||
return `(${min}, ${maxLabel}]`
|
return `(${min}, ${maxLabel}]`
|
||||||
|
|||||||
@@ -955,8 +955,6 @@ export default {
|
|||||||
imageOutputPrice: 'Image Output',
|
imageOutputPrice: 'Image Output',
|
||||||
perRequestPrice: 'Per Request',
|
perRequestPrice: 'Per Request',
|
||||||
intervals: 'Tiered Pricing',
|
intervals: 'Tiered Pricing',
|
||||||
tierLabel: 'Tier',
|
|
||||||
tokenRange: 'Token Range',
|
|
||||||
unitPerMillion: '/ 1M tokens',
|
unitPerMillion: '/ 1M tokens',
|
||||||
unitPerRequest: '/ request'
|
unitPerRequest: '/ request'
|
||||||
}
|
}
|
||||||
@@ -2048,8 +2046,6 @@ export default {
|
|||||||
imageOutputPrice: 'Image Output',
|
imageOutputPrice: 'Image Output',
|
||||||
perRequestPrice: 'Per Request',
|
perRequestPrice: 'Per Request',
|
||||||
intervals: 'Tiered Pricing',
|
intervals: 'Tiered Pricing',
|
||||||
tierLabel: 'Tier',
|
|
||||||
tokenRange: 'Token Range',
|
|
||||||
unitPerMillion: '/ 1M tokens',
|
unitPerMillion: '/ 1M tokens',
|
||||||
unitPerRequest: '/ request'
|
unitPerRequest: '/ request'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -959,8 +959,6 @@ export default {
|
|||||||
imageOutputPrice: '图片输出',
|
imageOutputPrice: '图片输出',
|
||||||
perRequestPrice: '每次请求',
|
perRequestPrice: '每次请求',
|
||||||
intervals: '阶梯定价',
|
intervals: '阶梯定价',
|
||||||
tierLabel: '层级',
|
|
||||||
tokenRange: 'Token 区间',
|
|
||||||
unitPerMillion: '/ 1M token',
|
unitPerMillion: '/ 1M token',
|
||||||
unitPerRequest: '/ 次'
|
unitPerRequest: '/ 次'
|
||||||
}
|
}
|
||||||
@@ -2127,8 +2125,6 @@ export default {
|
|||||||
imageOutputPrice: '图片输出',
|
imageOutputPrice: '图片输出',
|
||||||
perRequestPrice: '每次请求',
|
perRequestPrice: '每次请求',
|
||||||
intervals: '阶梯定价',
|
intervals: '阶梯定价',
|
||||||
tierLabel: '层级',
|
|
||||||
tokenRange: 'Token 区间',
|
|
||||||
unitPerMillion: '/ 1M token',
|
unitPerMillion: '/ 1M token',
|
||||||
unitPerRequest: '/ 次'
|
unitPerRequest: '/ 次'
|
||||||
}
|
}
|
||||||
|
|||||||
13
frontend/src/utils/pricing.ts
Normal file
13
frontend/src/utils/pricing.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* formatScaled formats a per-token (or per-request) USD price scaled by `scale`.
|
||||||
|
*
|
||||||
|
* formatScaled(0.000003, 1_000_000) → "$3" // per 1M tokens
|
||||||
|
* formatScaled(0.5, 1) → "$0.5" // per request
|
||||||
|
* formatScaled(null, 1_000_000) → "-"
|
||||||
|
*
|
||||||
|
* Uses toPrecision(10) then strips trailing zeros to avoid IEEE 754 display noise.
|
||||||
|
*/
|
||||||
|
export function formatScaled(value: number | null, scale: number): string {
|
||||||
|
if (value == null) return '-'
|
||||||
|
return `$${(value * scale).toPrecision(10).replace(/\.?0+$/, '')}`
|
||||||
|
}
|
||||||
@@ -59,11 +59,7 @@
|
|||||||
|
|
||||||
<template #cell-billing_model_source="{ row }">
|
<template #cell-billing_model_source="{ row }">
|
||||||
<span class="text-xs text-gray-700 dark:text-gray-300">
|
<span class="text-xs text-gray-700 dark:text-gray-300">
|
||||||
{{
|
{{ t(`admin.availableChannels.billingSource.${row.billing_model_source}`) }}
|
||||||
t(
|
|
||||||
`admin.availableChannels.billingSource.${row.billing_model_source || BILLING_MODEL_SOURCE_CHANNEL_MAPPED}`
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</AvailableChannelsTable>
|
</AvailableChannelsTable>
|
||||||
@@ -82,10 +78,7 @@ import AvailableChannelsTable from '@/components/channels/AvailableChannelsTable
|
|||||||
import channelsAPI, { type AvailableChannel } from '@/api/admin/channels'
|
import channelsAPI, { type AvailableChannel } from '@/api/admin/channels'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||||
import {
|
import { CHANNEL_STATUS_ACTIVE } from '@/constants/channel'
|
||||||
CHANNEL_STATUS_ACTIVE,
|
|
||||||
BILLING_MODEL_SOURCE_CHANNEL_MAPPED
|
|
||||||
} from '@/constants/channel'
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
|
|||||||
Reference in New Issue
Block a user