From f3d9491071d0dc8093c1c10de37b9ad78007b52f Mon Sep 17 00:00:00 2001 From: IanShaw027 Date: Thu, 13 Aug 2026 07:49:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=88=86=E7=BB=84=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E9=80=90=E6=A8=A1=E5=9E=8B=E5=AE=9A=E4=BB=B7=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E5=8F=AF=E5=85=B3=E9=97=AD=E9=95=BF=E4=B8=8A=E4=B8=8B=E6=96=87?= =?UTF-8?q?=E9=98=B6=E6=A2=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 运营需要按分组覆盖渠道/内置价,且部分套餐不应自动吃 200k 倍率。 原先只能改渠道价卡,分组侧只剩 Voice 三列。 - groups 新增 model_pricing / long_context_pricing_enabled,解析链改为 Group → Channel → 内置 - 关闭长上下文时 token 模型只取最低档;video 按秒计费可写进同一价卡 - 回退价对齐官方卡:4.5 缓存 $0.30、4.3/imagine/audio/search 默认值一并校正 --- backend/ent/group.go | 28 +++- backend/ent/group/group.go | 13 ++ backend/ent/group/where.go | 25 +++ backend/ent/group_create.go | 136 ++++++++++++++++ backend/ent/group_update.go | 93 +++++++++++ backend/ent/migrate/schema.go | 4 +- backend/ent/mutation.go | 146 +++++++++++++++++- backend/ent/runtime/runtime.go | 40 ++--- backend/ent/schema/group.go | 9 ++ .../internal/handler/admin/group_handler.go | 46 +++--- backend/internal/handler/dto/mappers.go | 2 + backend/internal/handler/dto/types.go | 16 +- backend/internal/repository/api_key_repo.go | 9 ++ backend/internal/repository/group_repo.go | 13 ++ backend/internal/service/admin_group.go | 38 +++++ backend/internal/service/admin_service.go | 42 ++--- .../service/billing_search_audio_cost_test.go | 10 +- backend/internal/service/billing_service.go | 108 ++++++++----- .../internal/service/billing_service_test.go | 4 +- backend/internal/service/channel.go | 60 +++---- backend/internal/service/channel_service.go | 2 +- .../internal/service/gateway_usage_billing.go | 43 +++++- backend/internal/service/group.go | 5 + .../service/model_pricing_resolver.go | 82 +++++++++- .../service/model_pricing_resolver_test.go | 52 +++++++ .../internal/service/openai_gateway_usage.go | 66 ++++++-- .../migrations/221_group_model_pricing.sql | 8 + .../admin/channel/PricingEntryCard.vue | 19 ++- frontend/src/constants/channel.ts | 2 + .../src/i18n/locales/en/admin/channels.ts | 5 +- .../src/i18n/locales/en/admin/overview.ts | 7 + .../src/i18n/locales/zh/admin/channels.ts | 5 +- .../src/i18n/locales/zh/admin/overview.ts | 7 + frontend/src/types/index.ts | 6 + frontend/src/views/admin/GroupsView.vue | 119 ++++++++++++++ 35 files changed, 1094 insertions(+), 176 deletions(-) create mode 100644 backend/migrations/221_group_model_pricing.sql diff --git a/backend/ent/group.go b/backend/ent/group.go index 3b1d2a4a51..110f06742a 100644 --- a/backend/ent/group.go +++ b/backend/ent/group.go @@ -97,6 +97,10 @@ type Group struct { AudioTtsPricePerMillionChars *float64 `json:"audio_tts_price_per_million_chars,omitempty"` // STT 每小时价格(USD) AudioSttPricePerHour *float64 `json:"audio_stt_price_per_hour,omitempty"` + // 是否按上下文长度应用模型阶梯价格 + LongContextPricingEnabled bool `json:"long_context_pricing_enabled,omitempty"` + // 分组逐模型定价;优先级高于渠道和内置定价 + ModelPricing json.RawMessage `json:"model_pricing,omitempty"` // 是否仅允许 Claude Code 客户端 ClaudeCodeOnly bool `json:"claude_code_only,omitempty"` // 非 Claude Code 请求降级使用的分组 ID @@ -245,9 +249,9 @@ func (*Group) scanValues(columns []string) ([]any, error) { values := make([]any, len(columns)) for i := range columns { switch columns[i] { - case group.FieldVideoModelPrices, group.FieldModelRouting, group.FieldSupportedModelScopes, group.FieldMessagesDispatchModelConfig, group.FieldModelsListConfig, group.FieldReasoningEffortMappings: + case group.FieldVideoModelPrices, group.FieldModelPricing, group.FieldModelRouting, group.FieldSupportedModelScopes, group.FieldMessagesDispatchModelConfig, group.FieldModelsListConfig, group.FieldReasoningEffortMappings: values[i] = new([]byte) - case group.FieldPeakRateEnabled, group.FieldIsExclusive, group.FieldAllowImageGeneration, group.FieldAllowBatchImageGeneration, group.FieldImageRateIndependent, group.FieldVideoRateIndependent, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch, group.FieldAllowLive, group.FieldRequireOauthOnly, group.FieldRequirePrivacySet, group.FieldProfitControlEnabled: + case group.FieldPeakRateEnabled, group.FieldIsExclusive, group.FieldAllowImageGeneration, group.FieldAllowBatchImageGeneration, group.FieldImageRateIndependent, group.FieldVideoRateIndependent, group.FieldLongContextPricingEnabled, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch, group.FieldAllowLive, group.FieldRequireOauthOnly, group.FieldRequirePrivacySet, group.FieldProfitControlEnabled: values[i] = new(sql.NullBool) case group.FieldRateMultiplier, group.FieldPeakRateMultiplier, group.FieldDailyLimitUsd, group.FieldWeeklyLimitUsd, group.FieldMonthlyLimitUsd, group.FieldImageRateMultiplier, group.FieldImagePrice1k, group.FieldImagePrice2k, group.FieldImagePrice4k, group.FieldBatchImageDiscountMultiplier, group.FieldBatchImageHoldMultiplier, group.FieldVideoRateMultiplier, group.FieldVideoPrice480p, group.FieldVideoPrice720p, group.FieldVideoPrice1080p, group.FieldWebSearchPricePerCall, group.FieldSearchPricePer1k, group.FieldAudioRealtimePricePerMin, group.FieldAudioTtsPricePerMillionChars, group.FieldAudioSttPricePerHour, group.FieldProfitMinMargin, group.FieldProfitSafetyBuffer: values[i] = new(sql.NullFloat64) @@ -531,6 +535,20 @@ func (_m *Group) assignValues(columns []string, values []any) error { _m.AudioSttPricePerHour = new(float64) *_m.AudioSttPricePerHour = value.Float64 } + case group.FieldLongContextPricingEnabled: + if value, ok := values[i].(*sql.NullBool); !ok { + return fmt.Errorf("unexpected type %T for field long_context_pricing_enabled", values[i]) + } else if value.Valid { + _m.LongContextPricingEnabled = value.Bool + } + case group.FieldModelPricing: + if value, ok := values[i].(*[]byte); !ok { + return fmt.Errorf("unexpected type %T for field model_pricing", values[i]) + } else if value != nil && len(*value) > 0 { + if err := json.Unmarshal(*value, &_m.ModelPricing); err != nil { + return fmt.Errorf("unmarshal field model_pricing: %w", err) + } + } case group.FieldClaudeCodeOnly: if value, ok := values[i].(*sql.NullBool); !ok { return fmt.Errorf("unexpected type %T for field claude_code_only", values[i]) @@ -896,6 +914,12 @@ func (_m *Group) String() string { builder.WriteString(fmt.Sprintf("%v", *v)) } builder.WriteString(", ") + builder.WriteString("long_context_pricing_enabled=") + builder.WriteString(fmt.Sprintf("%v", _m.LongContextPricingEnabled)) + builder.WriteString(", ") + builder.WriteString("model_pricing=") + builder.WriteString(fmt.Sprintf("%v", _m.ModelPricing)) + builder.WriteString(", ") builder.WriteString("claude_code_only=") builder.WriteString(fmt.Sprintf("%v", _m.ClaudeCodeOnly)) builder.WriteString(", ") diff --git a/backend/ent/group/group.go b/backend/ent/group/group.go index 76a07b96a0..5ec04d32ae 100644 --- a/backend/ent/group/group.go +++ b/backend/ent/group/group.go @@ -94,6 +94,10 @@ const ( FieldAudioTtsPricePerMillionChars = "audio_tts_price_per_million_chars" // FieldAudioSttPricePerHour holds the string denoting the audio_stt_price_per_hour field in the database. FieldAudioSttPricePerHour = "audio_stt_price_per_hour" + // FieldLongContextPricingEnabled holds the string denoting the long_context_pricing_enabled field in the database. + FieldLongContextPricingEnabled = "long_context_pricing_enabled" + // FieldModelPricing holds the string denoting the model_pricing field in the database. + FieldModelPricing = "model_pricing" // FieldClaudeCodeOnly holds the string denoting the claude_code_only field in the database. FieldClaudeCodeOnly = "claude_code_only" // FieldFallbackGroupID holds the string denoting the fallback_group_id field in the database. @@ -250,6 +254,8 @@ var Columns = []string{ FieldAudioRealtimePricePerMin, FieldAudioTtsPricePerMillionChars, FieldAudioSttPricePerHour, + FieldLongContextPricingEnabled, + FieldModelPricing, FieldClaudeCodeOnly, FieldFallbackGroupID, FieldFallbackGroupIDOnInvalidRequest, @@ -364,6 +370,8 @@ var ( AudioTtsPricePerMillionCharsValidator func(float64) error // AudioSttPricePerHourValidator is a validator for the "audio_stt_price_per_hour" field. It is called by the builders before save. AudioSttPricePerHourValidator func(float64) error + // DefaultLongContextPricingEnabled holds the default value on creation for the "long_context_pricing_enabled" field. + DefaultLongContextPricingEnabled bool // DefaultClaudeCodeOnly holds the default value on creation for the "claude_code_only" field. DefaultClaudeCodeOnly bool // DefaultModelRoutingEnabled holds the default value on creation for the "model_routing_enabled" field. @@ -604,6 +612,11 @@ func ByAudioSttPricePerHour(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldAudioSttPricePerHour, opts...).ToFunc() } +// ByLongContextPricingEnabled orders the results by the long_context_pricing_enabled field. +func ByLongContextPricingEnabled(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldLongContextPricingEnabled, opts...).ToFunc() +} + // ByClaudeCodeOnly orders the results by the claude_code_only field. func ByClaudeCodeOnly(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldClaudeCodeOnly, opts...).ToFunc() diff --git a/backend/ent/group/where.go b/backend/ent/group/where.go index 630ec582e8..685a48a922 100644 --- a/backend/ent/group/where.go +++ b/backend/ent/group/where.go @@ -245,6 +245,11 @@ func AudioSttPricePerHour(v float64) predicate.Group { return predicate.Group(sql.FieldEQ(FieldAudioSttPricePerHour, v)) } +// LongContextPricingEnabled applies equality check predicate on the "long_context_pricing_enabled" field. It's identical to LongContextPricingEnabledEQ. +func LongContextPricingEnabled(v bool) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldLongContextPricingEnabled, v)) +} + // ClaudeCodeOnly applies equality check predicate on the "claude_code_only" field. It's identical to ClaudeCodeOnlyEQ. func ClaudeCodeOnly(v bool) predicate.Group { return predicate.Group(sql.FieldEQ(FieldClaudeCodeOnly, v)) @@ -2045,6 +2050,26 @@ func AudioSttPricePerHourNotNil() predicate.Group { return predicate.Group(sql.FieldNotNull(FieldAudioSttPricePerHour)) } +// LongContextPricingEnabledEQ applies the EQ predicate on the "long_context_pricing_enabled" field. +func LongContextPricingEnabledEQ(v bool) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldLongContextPricingEnabled, v)) +} + +// LongContextPricingEnabledNEQ applies the NEQ predicate on the "long_context_pricing_enabled" field. +func LongContextPricingEnabledNEQ(v bool) predicate.Group { + return predicate.Group(sql.FieldNEQ(FieldLongContextPricingEnabled, v)) +} + +// ModelPricingIsNil applies the IsNil predicate on the "model_pricing" field. +func ModelPricingIsNil() predicate.Group { + return predicate.Group(sql.FieldIsNull(FieldModelPricing)) +} + +// ModelPricingNotNil applies the NotNil predicate on the "model_pricing" field. +func ModelPricingNotNil() predicate.Group { + return predicate.Group(sql.FieldNotNull(FieldModelPricing)) +} + // ClaudeCodeOnlyEQ applies the EQ predicate on the "claude_code_only" field. func ClaudeCodeOnlyEQ(v bool) predicate.Group { return predicate.Group(sql.FieldEQ(FieldClaudeCodeOnly, v)) diff --git a/backend/ent/group_create.go b/backend/ent/group_create.go index 3246b92a50..158dca1e40 100644 --- a/backend/ent/group_create.go +++ b/backend/ent/group_create.go @@ -4,6 +4,7 @@ package ent import ( "context" + "encoding/json" "errors" "fmt" "time" @@ -559,6 +560,26 @@ func (_c *GroupCreate) SetNillableAudioSttPricePerHour(v *float64) *GroupCreate return _c } +// SetLongContextPricingEnabled sets the "long_context_pricing_enabled" field. +func (_c *GroupCreate) SetLongContextPricingEnabled(v bool) *GroupCreate { + _c.mutation.SetLongContextPricingEnabled(v) + return _c +} + +// SetNillableLongContextPricingEnabled sets the "long_context_pricing_enabled" field if the given value is not nil. +func (_c *GroupCreate) SetNillableLongContextPricingEnabled(v *bool) *GroupCreate { + if v != nil { + _c.SetLongContextPricingEnabled(*v) + } + return _c +} + +// SetModelPricing sets the "model_pricing" field. +func (_c *GroupCreate) SetModelPricing(v json.RawMessage) *GroupCreate { + _c.mutation.SetModelPricing(v) + return _c +} + // SetClaudeCodeOnly sets the "claude_code_only" field. func (_c *GroupCreate) SetClaudeCodeOnly(v bool) *GroupCreate { _c.mutation.SetClaudeCodeOnly(v) @@ -1042,6 +1063,10 @@ func (_c *GroupCreate) defaults() error { v := group.DefaultVideoRateMultiplier _c.mutation.SetVideoRateMultiplier(v) } + if _, ok := _c.mutation.LongContextPricingEnabled(); !ok { + v := group.DefaultLongContextPricingEnabled + _c.mutation.SetLongContextPricingEnabled(v) + } if _, ok := _c.mutation.ClaudeCodeOnly(); !ok { v := group.DefaultClaudeCodeOnly _c.mutation.SetClaudeCodeOnly(v) @@ -1237,6 +1262,9 @@ func (_c *GroupCreate) check() error { return &ValidationError{Name: "audio_stt_price_per_hour", err: fmt.Errorf(`ent: validator failed for field "Group.audio_stt_price_per_hour": %w`, err)} } } + if _, ok := _c.mutation.LongContextPricingEnabled(); !ok { + return &ValidationError{Name: "long_context_pricing_enabled", err: errors.New(`ent: missing required field "Group.long_context_pricing_enabled"`)} + } if _, ok := _c.mutation.ClaudeCodeOnly(); !ok { return &ValidationError{Name: "claude_code_only", err: errors.New(`ent: missing required field "Group.claude_code_only"`)} } @@ -1484,6 +1512,14 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) { _spec.SetField(group.FieldAudioSttPricePerHour, field.TypeFloat64, value) _node.AudioSttPricePerHour = &value } + if value, ok := _c.mutation.LongContextPricingEnabled(); ok { + _spec.SetField(group.FieldLongContextPricingEnabled, field.TypeBool, value) + _node.LongContextPricingEnabled = value + } + if value, ok := _c.mutation.ModelPricing(); ok { + _spec.SetField(group.FieldModelPricing, field.TypeJSON, value) + _node.ModelPricing = value + } if value, ok := _c.mutation.ClaudeCodeOnly(); ok { _spec.SetField(group.FieldClaudeCodeOnly, field.TypeBool, value) _node.ClaudeCodeOnly = value @@ -2396,6 +2432,36 @@ func (u *GroupUpsert) ClearAudioSttPricePerHour() *GroupUpsert { return u } +// SetLongContextPricingEnabled sets the "long_context_pricing_enabled" field. +func (u *GroupUpsert) SetLongContextPricingEnabled(v bool) *GroupUpsert { + u.Set(group.FieldLongContextPricingEnabled, v) + return u +} + +// UpdateLongContextPricingEnabled sets the "long_context_pricing_enabled" field to the value that was provided on create. +func (u *GroupUpsert) UpdateLongContextPricingEnabled() *GroupUpsert { + u.SetExcluded(group.FieldLongContextPricingEnabled) + return u +} + +// SetModelPricing sets the "model_pricing" field. +func (u *GroupUpsert) SetModelPricing(v json.RawMessage) *GroupUpsert { + u.Set(group.FieldModelPricing, v) + return u +} + +// UpdateModelPricing sets the "model_pricing" field to the value that was provided on create. +func (u *GroupUpsert) UpdateModelPricing() *GroupUpsert { + u.SetExcluded(group.FieldModelPricing) + return u +} + +// ClearModelPricing clears the value of the "model_pricing" field. +func (u *GroupUpsert) ClearModelPricing() *GroupUpsert { + u.SetNull(group.FieldModelPricing) + return u +} + // SetClaudeCodeOnly sets the "claude_code_only" field. func (u *GroupUpsert) SetClaudeCodeOnly(v bool) *GroupUpsert { u.Set(group.FieldClaudeCodeOnly, v) @@ -3534,6 +3600,41 @@ func (u *GroupUpsertOne) ClearAudioSttPricePerHour() *GroupUpsertOne { }) } +// SetLongContextPricingEnabled sets the "long_context_pricing_enabled" field. +func (u *GroupUpsertOne) SetLongContextPricingEnabled(v bool) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.SetLongContextPricingEnabled(v) + }) +} + +// UpdateLongContextPricingEnabled sets the "long_context_pricing_enabled" field to the value that was provided on create. +func (u *GroupUpsertOne) UpdateLongContextPricingEnabled() *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.UpdateLongContextPricingEnabled() + }) +} + +// SetModelPricing sets the "model_pricing" field. +func (u *GroupUpsertOne) SetModelPricing(v json.RawMessage) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.SetModelPricing(v) + }) +} + +// UpdateModelPricing sets the "model_pricing" field to the value that was provided on create. +func (u *GroupUpsertOne) UpdateModelPricing() *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.UpdateModelPricing() + }) +} + +// ClearModelPricing clears the value of the "model_pricing" field. +func (u *GroupUpsertOne) ClearModelPricing() *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.ClearModelPricing() + }) +} + // SetClaudeCodeOnly sets the "claude_code_only" field. func (u *GroupUpsertOne) SetClaudeCodeOnly(v bool) *GroupUpsertOne { return u.Update(func(s *GroupUpsert) { @@ -4889,6 +4990,41 @@ func (u *GroupUpsertBulk) ClearAudioSttPricePerHour() *GroupUpsertBulk { }) } +// SetLongContextPricingEnabled sets the "long_context_pricing_enabled" field. +func (u *GroupUpsertBulk) SetLongContextPricingEnabled(v bool) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.SetLongContextPricingEnabled(v) + }) +} + +// UpdateLongContextPricingEnabled sets the "long_context_pricing_enabled" field to the value that was provided on create. +func (u *GroupUpsertBulk) UpdateLongContextPricingEnabled() *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.UpdateLongContextPricingEnabled() + }) +} + +// SetModelPricing sets the "model_pricing" field. +func (u *GroupUpsertBulk) SetModelPricing(v json.RawMessage) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.SetModelPricing(v) + }) +} + +// UpdateModelPricing sets the "model_pricing" field to the value that was provided on create. +func (u *GroupUpsertBulk) UpdateModelPricing() *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.UpdateModelPricing() + }) +} + +// ClearModelPricing clears the value of the "model_pricing" field. +func (u *GroupUpsertBulk) ClearModelPricing() *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.ClearModelPricing() + }) +} + // SetClaudeCodeOnly sets the "claude_code_only" field. func (u *GroupUpsertBulk) SetClaudeCodeOnly(v bool) *GroupUpsertBulk { return u.Update(func(s *GroupUpsert) { diff --git a/backend/ent/group_update.go b/backend/ent/group_update.go index de5474e069..beca0c71f0 100644 --- a/backend/ent/group_update.go +++ b/backend/ent/group_update.go @@ -4,6 +4,7 @@ package ent import ( "context" + "encoding/json" "errors" "fmt" "time" @@ -787,6 +788,38 @@ func (_u *GroupUpdate) ClearAudioSttPricePerHour() *GroupUpdate { return _u } +// SetLongContextPricingEnabled sets the "long_context_pricing_enabled" field. +func (_u *GroupUpdate) SetLongContextPricingEnabled(v bool) *GroupUpdate { + _u.mutation.SetLongContextPricingEnabled(v) + return _u +} + +// SetNillableLongContextPricingEnabled sets the "long_context_pricing_enabled" field if the given value is not nil. +func (_u *GroupUpdate) SetNillableLongContextPricingEnabled(v *bool) *GroupUpdate { + if v != nil { + _u.SetLongContextPricingEnabled(*v) + } + return _u +} + +// SetModelPricing sets the "model_pricing" field. +func (_u *GroupUpdate) SetModelPricing(v json.RawMessage) *GroupUpdate { + _u.mutation.SetModelPricing(v) + return _u +} + +// AppendModelPricing appends value to the "model_pricing" field. +func (_u *GroupUpdate) AppendModelPricing(v json.RawMessage) *GroupUpdate { + _u.mutation.AppendModelPricing(v) + return _u +} + +// ClearModelPricing clears the value of the "model_pricing" field. +func (_u *GroupUpdate) ClearModelPricing() *GroupUpdate { + _u.mutation.ClearModelPricing() + return _u +} + // SetClaudeCodeOnly sets the "claude_code_only" field. func (_u *GroupUpdate) SetClaudeCodeOnly(v bool) *GroupUpdate { _u.mutation.SetClaudeCodeOnly(v) @@ -1697,6 +1730,20 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) { if _u.mutation.AudioSttPricePerHourCleared() { _spec.ClearField(group.FieldAudioSttPricePerHour, field.TypeFloat64) } + if value, ok := _u.mutation.LongContextPricingEnabled(); ok { + _spec.SetField(group.FieldLongContextPricingEnabled, field.TypeBool, value) + } + if value, ok := _u.mutation.ModelPricing(); ok { + _spec.SetField(group.FieldModelPricing, field.TypeJSON, value) + } + if value, ok := _u.mutation.AppendedModelPricing(); ok { + _spec.AddModifier(func(u *sql.UpdateBuilder) { + sqljson.Append(u, group.FieldModelPricing, value) + }) + } + if _u.mutation.ModelPricingCleared() { + _spec.ClearField(group.FieldModelPricing, field.TypeJSON) + } if value, ok := _u.mutation.ClaudeCodeOnly(); ok { _spec.SetField(group.FieldClaudeCodeOnly, field.TypeBool, value) } @@ -2862,6 +2909,38 @@ func (_u *GroupUpdateOne) ClearAudioSttPricePerHour() *GroupUpdateOne { return _u } +// SetLongContextPricingEnabled sets the "long_context_pricing_enabled" field. +func (_u *GroupUpdateOne) SetLongContextPricingEnabled(v bool) *GroupUpdateOne { + _u.mutation.SetLongContextPricingEnabled(v) + return _u +} + +// SetNillableLongContextPricingEnabled sets the "long_context_pricing_enabled" field if the given value is not nil. +func (_u *GroupUpdateOne) SetNillableLongContextPricingEnabled(v *bool) *GroupUpdateOne { + if v != nil { + _u.SetLongContextPricingEnabled(*v) + } + return _u +} + +// SetModelPricing sets the "model_pricing" field. +func (_u *GroupUpdateOne) SetModelPricing(v json.RawMessage) *GroupUpdateOne { + _u.mutation.SetModelPricing(v) + return _u +} + +// AppendModelPricing appends value to the "model_pricing" field. +func (_u *GroupUpdateOne) AppendModelPricing(v json.RawMessage) *GroupUpdateOne { + _u.mutation.AppendModelPricing(v) + return _u +} + +// ClearModelPricing clears the value of the "model_pricing" field. +func (_u *GroupUpdateOne) ClearModelPricing() *GroupUpdateOne { + _u.mutation.ClearModelPricing() + return _u +} + // SetClaudeCodeOnly sets the "claude_code_only" field. func (_u *GroupUpdateOne) SetClaudeCodeOnly(v bool) *GroupUpdateOne { _u.mutation.SetClaudeCodeOnly(v) @@ -3802,6 +3881,20 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error) if _u.mutation.AudioSttPricePerHourCleared() { _spec.ClearField(group.FieldAudioSttPricePerHour, field.TypeFloat64) } + if value, ok := _u.mutation.LongContextPricingEnabled(); ok { + _spec.SetField(group.FieldLongContextPricingEnabled, field.TypeBool, value) + } + if value, ok := _u.mutation.ModelPricing(); ok { + _spec.SetField(group.FieldModelPricing, field.TypeJSON, value) + } + if value, ok := _u.mutation.AppendedModelPricing(); ok { + _spec.AddModifier(func(u *sql.UpdateBuilder) { + sqljson.Append(u, group.FieldModelPricing, value) + }) + } + if _u.mutation.ModelPricingCleared() { + _spec.ClearField(group.FieldModelPricing, field.TypeJSON) + } if value, ok := _u.mutation.ClaudeCodeOnly(); ok { _spec.SetField(group.FieldClaudeCodeOnly, field.TypeBool, value) } diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index 491f038bc7..d19cb1e19a 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -934,6 +934,8 @@ var ( {Name: "audio_realtime_price_per_min", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}}, {Name: "audio_tts_price_per_million_chars", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}}, {Name: "audio_stt_price_per_hour", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}}, + {Name: "long_context_pricing_enabled", Type: field.TypeBool, Default: false}, + {Name: "model_pricing", Type: field.TypeJSON, Nullable: true, SchemaType: map[string]string{"postgres": "jsonb"}}, {Name: "claude_code_only", Type: field.TypeBool, Default: false}, {Name: "fallback_group_id", Type: field.TypeInt64, Nullable: true}, {Name: "fallback_group_id_on_invalid_request", Type: field.TypeInt64, Nullable: true}, @@ -990,7 +992,7 @@ var ( { Name: "group_sort_order", Unique: false, - Columns: []*schema.Column{GroupsColumns[47]}, + Columns: []*schema.Column{GroupsColumns[49]}, }, { Name: "idx_groups_duplicate_operation_id_active", diff --git a/backend/ent/mutation.go b/backend/ent/mutation.go index 99e8a2cd95..da0500d9be 100644 --- a/backend/ent/mutation.go +++ b/backend/ent/mutation.go @@ -21907,6 +21907,9 @@ type GroupMutation struct { addaudio_tts_price_per_million_chars *float64 audio_stt_price_per_hour *float64 addaudio_stt_price_per_hour *float64 + long_context_pricing_enabled *bool + model_pricing *json.RawMessage + appendmodel_pricing json.RawMessage claude_code_only *bool fallback_group_id *int64 addfallback_group_id *int64 @@ -24130,6 +24133,107 @@ func (m *GroupMutation) ResetAudioSttPricePerHour() { delete(m.clearedFields, group.FieldAudioSttPricePerHour) } +// SetLongContextPricingEnabled sets the "long_context_pricing_enabled" field. +func (m *GroupMutation) SetLongContextPricingEnabled(b bool) { + m.long_context_pricing_enabled = &b +} + +// LongContextPricingEnabled returns the value of the "long_context_pricing_enabled" field in the mutation. +func (m *GroupMutation) LongContextPricingEnabled() (r bool, exists bool) { + v := m.long_context_pricing_enabled + if v == nil { + return + } + return *v, true +} + +// OldLongContextPricingEnabled returns the old "long_context_pricing_enabled" field's value of the Group entity. +// If the Group object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *GroupMutation) OldLongContextPricingEnabled(ctx context.Context) (v bool, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldLongContextPricingEnabled is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldLongContextPricingEnabled requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldLongContextPricingEnabled: %w", err) + } + return oldValue.LongContextPricingEnabled, nil +} + +// ResetLongContextPricingEnabled resets all changes to the "long_context_pricing_enabled" field. +func (m *GroupMutation) ResetLongContextPricingEnabled() { + m.long_context_pricing_enabled = nil +} + +// SetModelPricing sets the "model_pricing" field. +func (m *GroupMutation) SetModelPricing(jm json.RawMessage) { + m.model_pricing = &jm + m.appendmodel_pricing = nil +} + +// ModelPricing returns the value of the "model_pricing" field in the mutation. +func (m *GroupMutation) ModelPricing() (r json.RawMessage, exists bool) { + v := m.model_pricing + if v == nil { + return + } + return *v, true +} + +// OldModelPricing returns the old "model_pricing" field's value of the Group entity. +// If the Group object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *GroupMutation) OldModelPricing(ctx context.Context) (v json.RawMessage, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldModelPricing is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldModelPricing requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldModelPricing: %w", err) + } + return oldValue.ModelPricing, nil +} + +// AppendModelPricing adds jm to the "model_pricing" field. +func (m *GroupMutation) AppendModelPricing(jm json.RawMessage) { + m.appendmodel_pricing = append(m.appendmodel_pricing, jm...) +} + +// AppendedModelPricing returns the list of values that were appended to the "model_pricing" field in this mutation. +func (m *GroupMutation) AppendedModelPricing() (json.RawMessage, bool) { + if len(m.appendmodel_pricing) == 0 { + return nil, false + } + return m.appendmodel_pricing, true +} + +// ClearModelPricing clears the value of the "model_pricing" field. +func (m *GroupMutation) ClearModelPricing() { + m.model_pricing = nil + m.appendmodel_pricing = nil + m.clearedFields[group.FieldModelPricing] = struct{}{} +} + +// ModelPricingCleared returns if the "model_pricing" field was cleared in this mutation. +func (m *GroupMutation) ModelPricingCleared() bool { + _, ok := m.clearedFields[group.FieldModelPricing] + return ok +} + +// ResetModelPricing resets all changes to the "model_pricing" field. +func (m *GroupMutation) ResetModelPricing() { + m.model_pricing = nil + m.appendmodel_pricing = nil + delete(m.clearedFields, group.FieldModelPricing) +} + // SetClaudeCodeOnly sets the "claude_code_only" field. func (m *GroupMutation) SetClaudeCodeOnly(b bool) { m.claude_code_only = &b @@ -25435,7 +25539,7 @@ func (m *GroupMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *GroupMutation) Fields() []string { - fields := make([]string, 0, 60) + fields := make([]string, 0, 62) if m.created_at != nil { fields = append(fields, group.FieldCreatedAt) } @@ -25553,6 +25657,12 @@ func (m *GroupMutation) Fields() []string { if m.audio_stt_price_per_hour != nil { fields = append(fields, group.FieldAudioSttPricePerHour) } + if m.long_context_pricing_enabled != nil { + fields = append(fields, group.FieldLongContextPricingEnabled) + } + if m.model_pricing != nil { + fields = append(fields, group.FieldModelPricing) + } if m.claude_code_only != nil { fields = append(fields, group.FieldClaudeCodeOnly) } @@ -25702,6 +25812,10 @@ func (m *GroupMutation) Field(name string) (ent.Value, bool) { return m.AudioTtsPricePerMillionChars() case group.FieldAudioSttPricePerHour: return m.AudioSttPricePerHour() + case group.FieldLongContextPricingEnabled: + return m.LongContextPricingEnabled() + case group.FieldModelPricing: + return m.ModelPricing() case group.FieldClaudeCodeOnly: return m.ClaudeCodeOnly() case group.FieldFallbackGroupID: @@ -25831,6 +25945,10 @@ func (m *GroupMutation) OldField(ctx context.Context, name string) (ent.Value, e return m.OldAudioTtsPricePerMillionChars(ctx) case group.FieldAudioSttPricePerHour: return m.OldAudioSttPricePerHour(ctx) + case group.FieldLongContextPricingEnabled: + return m.OldLongContextPricingEnabled(ctx) + case group.FieldModelPricing: + return m.OldModelPricing(ctx) case group.FieldClaudeCodeOnly: return m.OldClaudeCodeOnly(ctx) case group.FieldFallbackGroupID: @@ -26155,6 +26273,20 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error { } m.SetAudioSttPricePerHour(v) return nil + case group.FieldLongContextPricingEnabled: + v, ok := value.(bool) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetLongContextPricingEnabled(v) + return nil + case group.FieldModelPricing: + v, ok := value.(json.RawMessage) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetModelPricing(v) + return nil case group.FieldClaudeCodeOnly: v, ok := value.(bool) if !ok { @@ -26713,6 +26845,9 @@ func (m *GroupMutation) ClearedFields() []string { if m.FieldCleared(group.FieldAudioSttPricePerHour) { fields = append(fields, group.FieldAudioSttPricePerHour) } + if m.FieldCleared(group.FieldModelPricing) { + fields = append(fields, group.FieldModelPricing) + } if m.FieldCleared(group.FieldFallbackGroupID) { fields = append(fields, group.FieldFallbackGroupID) } @@ -26790,6 +26925,9 @@ func (m *GroupMutation) ClearField(name string) error { case group.FieldAudioSttPricePerHour: m.ClearAudioSttPricePerHour() return nil + case group.FieldModelPricing: + m.ClearModelPricing() + return nil case group.FieldFallbackGroupID: m.ClearFallbackGroupID() return nil @@ -26924,6 +27062,12 @@ func (m *GroupMutation) ResetField(name string) error { case group.FieldAudioSttPricePerHour: m.ResetAudioSttPricePerHour() return nil + case group.FieldLongContextPricingEnabled: + m.ResetLongContextPricingEnabled() + return nil + case group.FieldModelPricing: + m.ResetModelPricing() + return nil case group.FieldClaudeCodeOnly: m.ResetClaudeCodeOnly() return nil diff --git a/backend/ent/runtime/runtime.go b/backend/ent/runtime/runtime.go index 8042a835a9..4be8d374d6 100644 --- a/backend/ent/runtime/runtime.go +++ b/backend/ent/runtime/runtime.go @@ -1133,80 +1133,84 @@ func init() { groupDescAudioSttPricePerHour := groupFields[35].Descriptor() // group.AudioSttPricePerHourValidator is a validator for the "audio_stt_price_per_hour" field. It is called by the builders before save. group.AudioSttPricePerHourValidator = groupDescAudioSttPricePerHour.Validators[0].(func(float64) error) + // groupDescLongContextPricingEnabled is the schema descriptor for long_context_pricing_enabled field. + groupDescLongContextPricingEnabled := groupFields[36].Descriptor() + // group.DefaultLongContextPricingEnabled holds the default value on creation for the long_context_pricing_enabled field. + group.DefaultLongContextPricingEnabled = groupDescLongContextPricingEnabled.Default.(bool) // groupDescClaudeCodeOnly is the schema descriptor for claude_code_only field. - groupDescClaudeCodeOnly := groupFields[36].Descriptor() + groupDescClaudeCodeOnly := groupFields[38].Descriptor() // group.DefaultClaudeCodeOnly holds the default value on creation for the claude_code_only field. group.DefaultClaudeCodeOnly = groupDescClaudeCodeOnly.Default.(bool) // groupDescModelRoutingEnabled is the schema descriptor for model_routing_enabled field. - groupDescModelRoutingEnabled := groupFields[40].Descriptor() + groupDescModelRoutingEnabled := groupFields[42].Descriptor() // group.DefaultModelRoutingEnabled holds the default value on creation for the model_routing_enabled field. group.DefaultModelRoutingEnabled = groupDescModelRoutingEnabled.Default.(bool) // groupDescMcpXMLInject is the schema descriptor for mcp_xml_inject field. - groupDescMcpXMLInject := groupFields[41].Descriptor() + groupDescMcpXMLInject := groupFields[43].Descriptor() // group.DefaultMcpXMLInject holds the default value on creation for the mcp_xml_inject field. group.DefaultMcpXMLInject = groupDescMcpXMLInject.Default.(bool) // groupDescSupportedModelScopes is the schema descriptor for supported_model_scopes field. - groupDescSupportedModelScopes := groupFields[42].Descriptor() + groupDescSupportedModelScopes := groupFields[44].Descriptor() // group.DefaultSupportedModelScopes holds the default value on creation for the supported_model_scopes field. group.DefaultSupportedModelScopes = groupDescSupportedModelScopes.Default.([]string) // groupDescSortOrder is the schema descriptor for sort_order field. - groupDescSortOrder := groupFields[43].Descriptor() + groupDescSortOrder := groupFields[45].Descriptor() // group.DefaultSortOrder holds the default value on creation for the sort_order field. group.DefaultSortOrder = groupDescSortOrder.Default.(int) // groupDescAllowMessagesDispatch is the schema descriptor for allow_messages_dispatch field. - groupDescAllowMessagesDispatch := groupFields[44].Descriptor() + groupDescAllowMessagesDispatch := groupFields[46].Descriptor() // group.DefaultAllowMessagesDispatch holds the default value on creation for the allow_messages_dispatch field. group.DefaultAllowMessagesDispatch = groupDescAllowMessagesDispatch.Default.(bool) // groupDescAllowLive is the schema descriptor for allow_live field. - groupDescAllowLive := groupFields[45].Descriptor() + groupDescAllowLive := groupFields[47].Descriptor() // group.DefaultAllowLive holds the default value on creation for the allow_live field. group.DefaultAllowLive = groupDescAllowLive.Default.(bool) // groupDescRequireOauthOnly is the schema descriptor for require_oauth_only field. - groupDescRequireOauthOnly := groupFields[46].Descriptor() + groupDescRequireOauthOnly := groupFields[48].Descriptor() // group.DefaultRequireOauthOnly holds the default value on creation for the require_oauth_only field. group.DefaultRequireOauthOnly = groupDescRequireOauthOnly.Default.(bool) // groupDescRequirePrivacySet is the schema descriptor for require_privacy_set field. - groupDescRequirePrivacySet := groupFields[47].Descriptor() + groupDescRequirePrivacySet := groupFields[49].Descriptor() // group.DefaultRequirePrivacySet holds the default value on creation for the require_privacy_set field. group.DefaultRequirePrivacySet = groupDescRequirePrivacySet.Default.(bool) // groupDescDefaultMappedModel is the schema descriptor for default_mapped_model field. - groupDescDefaultMappedModel := groupFields[48].Descriptor() + groupDescDefaultMappedModel := groupFields[50].Descriptor() // group.DefaultDefaultMappedModel holds the default value on creation for the default_mapped_model field. group.DefaultDefaultMappedModel = groupDescDefaultMappedModel.Default.(string) // group.DefaultMappedModelValidator is a validator for the "default_mapped_model" field. It is called by the builders before save. group.DefaultMappedModelValidator = groupDescDefaultMappedModel.Validators[0].(func(string) error) // groupDescMessagesDispatchModelConfig is the schema descriptor for messages_dispatch_model_config field. - groupDescMessagesDispatchModelConfig := groupFields[49].Descriptor() + groupDescMessagesDispatchModelConfig := groupFields[51].Descriptor() // group.DefaultMessagesDispatchModelConfig holds the default value on creation for the messages_dispatch_model_config field. group.DefaultMessagesDispatchModelConfig = groupDescMessagesDispatchModelConfig.Default.(domain.OpenAIMessagesDispatchModelConfig) // groupDescModelsListConfig is the schema descriptor for models_list_config field. - groupDescModelsListConfig := groupFields[50].Descriptor() + groupDescModelsListConfig := groupFields[52].Descriptor() // group.DefaultModelsListConfig holds the default value on creation for the models_list_config field. group.DefaultModelsListConfig = groupDescModelsListConfig.Default.(domain.GroupModelsListConfig) // groupDescRpmLimit is the schema descriptor for rpm_limit field. - groupDescRpmLimit := groupFields[51].Descriptor() + groupDescRpmLimit := groupFields[53].Descriptor() // group.DefaultRpmLimit holds the default value on creation for the rpm_limit field. group.DefaultRpmLimit = groupDescRpmLimit.Default.(int) // groupDescMaxReasoningEffort is the schema descriptor for max_reasoning_effort field. - groupDescMaxReasoningEffort := groupFields[52].Descriptor() + groupDescMaxReasoningEffort := groupFields[54].Descriptor() // group.DefaultMaxReasoningEffort holds the default value on creation for the max_reasoning_effort field. group.DefaultMaxReasoningEffort = groupDescMaxReasoningEffort.Default.(string) // group.MaxReasoningEffortValidator is a validator for the "max_reasoning_effort" field. It is called by the builders before save. group.MaxReasoningEffortValidator = groupDescMaxReasoningEffort.Validators[0].(func(string) error) // groupDescReasoningEffortMappings is the schema descriptor for reasoning_effort_mappings field. - groupDescReasoningEffortMappings := groupFields[53].Descriptor() + groupDescReasoningEffortMappings := groupFields[55].Descriptor() // group.DefaultReasoningEffortMappings holds the default value on creation for the reasoning_effort_mappings field. group.DefaultReasoningEffortMappings = groupDescReasoningEffortMappings.Default.([]domain.ReasoningEffortMapping) // groupDescProfitControlEnabled is the schema descriptor for profit_control_enabled field. - groupDescProfitControlEnabled := groupFields[54].Descriptor() + groupDescProfitControlEnabled := groupFields[56].Descriptor() // group.DefaultProfitControlEnabled holds the default value on creation for the profit_control_enabled field. group.DefaultProfitControlEnabled = groupDescProfitControlEnabled.Default.(bool) // groupDescProfitMinMargin is the schema descriptor for profit_min_margin field. - groupDescProfitMinMargin := groupFields[55].Descriptor() + groupDescProfitMinMargin := groupFields[57].Descriptor() // group.DefaultProfitMinMargin holds the default value on creation for the profit_min_margin field. group.DefaultProfitMinMargin = groupDescProfitMinMargin.Default.(float64) // groupDescProfitSafetyBuffer is the schema descriptor for profit_safety_buffer field. - groupDescProfitSafetyBuffer := groupFields[56].Descriptor() + groupDescProfitSafetyBuffer := groupFields[58].Descriptor() // group.DefaultProfitSafetyBuffer holds the default value on creation for the profit_safety_buffer field. group.DefaultProfitSafetyBuffer = groupDescProfitSafetyBuffer.Default.(float64) idempotencyrecordMixin := schema.IdempotencyRecord{}.Mixin() diff --git a/backend/ent/schema/group.go b/backend/ent/schema/group.go index 85f86891b8..cafd6dcafe 100644 --- a/backend/ent/schema/group.go +++ b/backend/ent/schema/group.go @@ -1,6 +1,8 @@ package schema import ( + "encoding/json" + "github.com/Wei-Shaw/sub2api/ent/schema/mixins" "github.com/Wei-Shaw/sub2api/internal/domain" @@ -185,6 +187,13 @@ func (Group) Fields() []ent.Field { Min(0). SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}). Comment("STT 每小时价格(USD)"), + field.Bool("long_context_pricing_enabled"). + Default(false). + Comment("是否按上下文长度应用模型阶梯价格"), + field.JSON("model_pricing", json.RawMessage{}). + Optional(). + SchemaType(map[string]string{dialect.Postgres: "jsonb"}). + Comment("分组逐模型定价;优先级高于渠道和内置定价"), // Claude Code 客户端限制 (added by migration 029) field.Bool("claude_code_only"). diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go index 8ddbfef183..b8b9587f72 100644 --- a/backend/internal/handler/admin/group_handler.go +++ b/backend/internal/handler/admin/group_handler.go @@ -96,15 +96,17 @@ func NewGroupHandler(adminService service.AdminService, dashboardService *servic // CreateGroupRequest represents create group request type CreateGroupRequest struct { - Name string `json:"name" binding:"required"` - Description string `json:"description"` - Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok composite"` - RateMultiplier float64 `json:"rate_multiplier"` - IsExclusive bool `json:"is_exclusive"` - SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"` - DailyLimitUSD optionalLimitField `json:"daily_limit_usd"` - WeeklyLimitUSD optionalLimitField `json:"weekly_limit_usd"` - MonthlyLimitUSD optionalLimitField `json:"monthly_limit_usd"` + Name string `json:"name" binding:"required"` + Description string `json:"description"` + Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok composite"` + RateMultiplier float64 `json:"rate_multiplier"` + IsExclusive bool `json:"is_exclusive"` + SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"` + DailyLimitUSD optionalLimitField `json:"daily_limit_usd"` + WeeklyLimitUSD optionalLimitField `json:"weekly_limit_usd"` + MonthlyLimitUSD optionalLimitField `json:"monthly_limit_usd"` + LongContextPricingEnabled bool `json:"long_context_pricing_enabled"` + ModelPricing []service.ChannelModelPricing `json:"model_pricing"` // 图片生成计费配置(antigravity 和 gemini 平台使用,负数表示清除配置) AllowImageGeneration bool `json:"allow_image_generation"` AllowBatchImageGeneration bool `json:"allow_batch_image_generation"` @@ -162,16 +164,18 @@ type CreateGroupRequest struct { // UpdateGroupRequest represents update group request type UpdateGroupRequest struct { - Name string `json:"name"` - Description *string `json:"description"` - Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok composite"` - RateMultiplier *float64 `json:"rate_multiplier"` - IsExclusive *bool `json:"is_exclusive"` - Status string `json:"status" binding:"omitempty,oneof=active inactive"` - SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"` - DailyLimitUSD optionalLimitField `json:"daily_limit_usd"` - WeeklyLimitUSD optionalLimitField `json:"weekly_limit_usd"` - MonthlyLimitUSD optionalLimitField `json:"monthly_limit_usd"` + Name string `json:"name"` + Description *string `json:"description"` + Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok composite"` + RateMultiplier *float64 `json:"rate_multiplier"` + IsExclusive *bool `json:"is_exclusive"` + Status string `json:"status" binding:"omitempty,oneof=active inactive"` + SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"` + DailyLimitUSD optionalLimitField `json:"daily_limit_usd"` + WeeklyLimitUSD optionalLimitField `json:"weekly_limit_usd"` + MonthlyLimitUSD optionalLimitField `json:"monthly_limit_usd"` + LongContextPricingEnabled *bool `json:"long_context_pricing_enabled"` + ModelPricing *[]service.ChannelModelPricing `json:"model_pricing"` // 图片生成计费配置(antigravity 和 gemini 平台使用,负数表示清除配置) AllowImageGeneration *bool `json:"allow_image_generation"` AllowBatchImageGeneration *bool `json:"allow_batch_image_generation"` @@ -508,6 +512,8 @@ func (h *GroupHandler) Create(c *gin.Context) { DailyLimitUSD: req.DailyLimitUSD.ToServiceInput(), WeeklyLimitUSD: req.WeeklyLimitUSD.ToServiceInput(), MonthlyLimitUSD: req.MonthlyLimitUSD.ToServiceInput(), + LongContextPricingEnabled: req.LongContextPricingEnabled, + ModelPricing: req.ModelPricing, AllowImageGeneration: req.AllowImageGeneration, AllowBatchImageGeneration: req.AllowBatchImageGeneration, ImageRateIndependent: req.ImageRateIndependent, @@ -635,6 +641,8 @@ func (h *GroupHandler) Update(c *gin.Context) { DailyLimitUSD: req.DailyLimitUSD.ToServiceInput(), WeeklyLimitUSD: req.WeeklyLimitUSD.ToServiceInput(), MonthlyLimitUSD: req.MonthlyLimitUSD.ToServiceInput(), + LongContextPricingEnabled: req.LongContextPricingEnabled, + ModelPricing: req.ModelPricing, AllowImageGeneration: req.AllowImageGeneration, AllowBatchImageGeneration: req.AllowBatchImageGeneration, ImageRateIndependent: req.ImageRateIndependent, diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index 0c7d892297..d5a9403a23 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -149,6 +149,7 @@ func GroupFromServiceAdmin(g *service.Group) *AdminGroup { ProfitControlEnabled: g.ProfitControlEnabled, ProfitMinMargin: g.ProfitMinMargin, ProfitSafetyBuffer: g.ProfitSafetyBuffer, + ModelPricing: g.ModelPricing, ModelRouting: g.ModelRouting, ModelRoutingEnabled: g.ModelRoutingEnabled, MCPXMLInject: g.MCPXMLInject, @@ -184,6 +185,7 @@ func groupFromServiceBase(g *service.Group) Group { DailyLimitUSD: g.DailyLimitUSD, WeeklyLimitUSD: g.WeeklyLimitUSD, MonthlyLimitUSD: g.MonthlyLimitUSD, + LongContextPricingEnabled: g.LongContextPricingEnabled, AllowImageGeneration: g.AllowImageGeneration, AllowBatchImageGeneration: g.AllowBatchImageGeneration, ImageRateIndependent: g.ImageRateIndependent, diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index bc2c502c1f..2cd623f7d6 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -96,10 +96,11 @@ type Group struct { IsExclusive bool `json:"is_exclusive"` Status string `json:"status"` - SubscriptionType string `json:"subscription_type"` - DailyLimitUSD *float64 `json:"daily_limit_usd"` - WeeklyLimitUSD *float64 `json:"weekly_limit_usd"` - MonthlyLimitUSD *float64 `json:"monthly_limit_usd"` + SubscriptionType string `json:"subscription_type"` + DailyLimitUSD *float64 `json:"daily_limit_usd"` + WeeklyLimitUSD *float64 `json:"weekly_limit_usd"` + MonthlyLimitUSD *float64 `json:"monthly_limit_usd"` + LongContextPricingEnabled bool `json:"long_context_pricing_enabled"` // 图片生成计费配置(仅 antigravity 平台使用) AllowImageGeneration bool `json:"allow_image_generation"` @@ -164,9 +165,10 @@ type AdminGroup struct { // 分组利润控制(五个 token 平台分组可启用;margin/buffer 为小数存储)。 // 仅管理员可见:这三个字段与同响应中的 rate_multiplier 相乘即可反推出 // 运营方的上游成本上限,属于内部经营信息,不得下放到 dto.Group。 - ProfitControlEnabled bool `json:"profit_control_enabled"` - ProfitMinMargin float64 `json:"profit_min_margin"` - ProfitSafetyBuffer float64 `json:"profit_safety_buffer"` + ProfitControlEnabled bool `json:"profit_control_enabled"` + ProfitMinMargin float64 `json:"profit_min_margin"` + ProfitSafetyBuffer float64 `json:"profit_safety_buffer"` + ModelPricing []service.ChannelModelPricing `json:"model_pricing"` // 模型路由配置(仅 anthropic 平台使用) ModelRouting map[string][]int64 `json:"model_routing"` diff --git a/backend/internal/repository/api_key_repo.go b/backend/internal/repository/api_key_repo.go index a1c7cad801..777889b630 100644 --- a/backend/internal/repository/api_key_repo.go +++ b/backend/internal/repository/api_key_repo.go @@ -3,6 +3,7 @@ package repository import ( "context" "database/sql" + "encoding/json" "errors" "fmt" "strings" @@ -196,6 +197,8 @@ func (r *apiKeyRepository) GetByKeyForAuth(ctx context.Context, key string) (*se group.FieldAudioRealtimePricePerMin, group.FieldAudioTtsPricePerMillionChars, group.FieldAudioSttPricePerHour, + group.FieldLongContextPricingEnabled, + group.FieldModelPricing, group.FieldClaudeCodeOnly, group.FieldFallbackGroupID, group.FieldFallbackGroupIDOnInvalidRequest, @@ -946,6 +949,10 @@ func groupEntityToService(g *dbent.Group) *service.Group { if g == nil { return nil } + var modelPricing []service.ChannelModelPricing + if len(g.ModelPricing) > 0 { + _ = json.Unmarshal(g.ModelPricing, &modelPricing) + } return &service.Group{ ID: g.ID, Name: g.Name, @@ -980,6 +987,8 @@ func groupEntityToService(g *dbent.Group) *service.Group { AudioRealtimePricePerMin: g.AudioRealtimePricePerMin, AudioTTSPricePerMillionChars: g.AudioTtsPricePerMillionChars, AudioSTTPricePerHour: g.AudioSttPricePerHour, + LongContextPricingEnabled: g.LongContextPricingEnabled, + ModelPricing: modelPricing, DefaultValidityDays: g.DefaultValidityDays, ClaudeCodeOnly: g.ClaudeCodeOnly, FallbackGroupID: g.FallbackGroupID, diff --git a/backend/internal/repository/group_repo.go b/backend/internal/repository/group_repo.go index 4cbc27909d..b5de46353d 100644 --- a/backend/internal/repository/group_repo.go +++ b/backend/internal/repository/group_repo.go @@ -3,6 +3,7 @@ package repository import ( "context" "database/sql" + "encoding/json" "errors" "fmt" "sort" @@ -56,6 +57,10 @@ func createGroupRecord(ctx context.Context, client *dbent.Client, groupIn *servi if groupIn == nil { return errors.New("group is nil") } + modelPricing, err := json.Marshal(groupIn.ModelPricing) + if err != nil { + return fmt.Errorf("marshal group model pricing: %w", err) + } builder := client.Group.Create(). SetName(groupIn.Name). SetDescription(groupIn.Description). @@ -88,6 +93,8 @@ func createGroupRecord(ctx context.Context, client *dbent.Client, groupIn *servi SetNillableAudioRealtimePricePerMin(groupIn.AudioRealtimePricePerMin). SetNillableAudioTtsPricePerMillionChars(groupIn.AudioTTSPricePerMillionChars). SetNillableAudioSttPricePerHour(groupIn.AudioSTTPricePerHour). + SetLongContextPricingEnabled(groupIn.LongContextPricingEnabled). + SetModelPricing(modelPricing). SetDefaultValidityDays(groupIn.DefaultValidityDays). SetClaudeCodeOnly(groupIn.ClaudeCodeOnly). SetNillableFallbackGroupID(groupIn.FallbackGroupID). @@ -234,6 +241,10 @@ func (r *groupRepository) GetByIDLite(ctx context.Context, id int64) (*service.G } func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) error { + modelPricing, err := json.Marshal(groupIn.ModelPricing) + if err != nil { + return fmt.Errorf("marshal group model pricing: %w", err) + } builder := r.client.Group.UpdateOneID(groupIn.ID). SetName(groupIn.Name). SetDescription(groupIn.Description). @@ -260,6 +271,8 @@ func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) er SetNillableVideoPrice720p(groupIn.VideoPrice720P). SetNillableVideoPrice1080p(groupIn.VideoPrice1080P). SetVideoModelPrices(service.NormalizeVideoModelPrices(groupIn.VideoModelPrices)). + SetLongContextPricingEnabled(groupIn.LongContextPricingEnabled). + SetModelPricing(modelPricing). SetDefaultValidityDays(groupIn.DefaultValidityDays). SetClaudeCodeOnly(groupIn.ClaudeCodeOnly). SetModelRoutingEnabled(groupIn.ModelRoutingEnabled). diff --git a/backend/internal/service/admin_group.go b/backend/internal/service/admin_group.go index 5744f40cfe..98f4ab6d31 100644 --- a/backend/internal/service/admin_group.go +++ b/backend/internal/service/admin_group.go @@ -301,6 +301,10 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn } platform := NormalizeGroupPlatform(input.Platform) + modelPricing, err := normalizeGroupModelPricing(platform, input.ModelPricing) + if err != nil { + return nil, err + } maxReasoningEffort, err := normalizeMaxReasoningEffortForPlatform(platform, input.MaxReasoningEffort) if err != nil { return nil, infraerrors.Newf(http.StatusBadRequest, "INVALID_MAX_REASONING_EFFORT", "%v", err) @@ -459,6 +463,8 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn DailyLimitUSD: dailyLimit, WeeklyLimitUSD: weeklyLimit, MonthlyLimitUSD: monthlyLimit, + LongContextPricingEnabled: input.LongContextPricingEnabled, + ModelPricing: modelPricing, AllowImageGeneration: allowImageGeneration, AllowBatchImageGeneration: allowBatchImageGeneration, ImageRateIndependent: input.ImageRateIndependent, @@ -656,6 +662,16 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd if input.Status != "" { group.Status = input.Status } + if input.LongContextPricingEnabled != nil { + group.LongContextPricingEnabled = *input.LongContextPricingEnabled + } + if input.ModelPricing != nil { + modelPricing, normalizeErr := normalizeGroupModelPricing(group.Platform, *input.ModelPricing) + if normalizeErr != nil { + return nil, normalizeErr + } + group.ModelPricing = modelPricing + } // 订阅相关字段 if input.SubscriptionType != "" { @@ -955,6 +971,28 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd return group, nil } +func normalizeGroupModelPricing(platform string, pricing []ChannelModelPricing) ([]ChannelModelPricing, error) { + out := make([]ChannelModelPricing, len(pricing)) + for i := range pricing { + out[i] = pricing[i].Clone() + out[i].ID = 0 + out[i].ChannelID = 0 + if strings.TrimSpace(out[i].Platform) == "" { + out[i].Platform = platform + } + for j := range out[i].Models { + out[i].Models[j] = strings.TrimSpace(out[i].Models[j]) + } + if len(out[i].Models) == 0 { + return nil, infraerrors.New(http.StatusBadRequest, "GROUP_MODEL_PRICING_MODELS_REQUIRED", "group model pricing entry requires at least one model") + } + } + if err := validatePricingEntries(out); err != nil { + return nil, err + } + return out, nil +} + func (s *adminServiceImpl) DeleteGroup(ctx context.Context, id int64) error { var groupKeys []string if s.authCacheInvalidator != nil { diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index d9c4060c9e..31d0e48649 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -209,15 +209,17 @@ type AdminBoundAuthIdentityChannel struct { } type CreateGroupInput struct { - Name string - Description string - Platform string - RateMultiplier float64 - IsExclusive bool - SubscriptionType string // standard/subscription - DailyLimitUSD *float64 // 日限额 (USD) - WeeklyLimitUSD *float64 // 周限额 (USD) - MonthlyLimitUSD *float64 // 月限额 (USD) + Name string + Description string + Platform string + RateMultiplier float64 + IsExclusive bool + SubscriptionType string // standard/subscription + DailyLimitUSD *float64 // 日限额 (USD) + WeeklyLimitUSD *float64 // 周限额 (USD) + MonthlyLimitUSD *float64 // 月限额 (USD) + LongContextPricingEnabled bool + ModelPricing []ChannelModelPricing // 图片生成计费配置(仅 antigravity 平台使用) AllowImageGeneration bool AllowBatchImageGeneration bool @@ -281,16 +283,18 @@ type CreateGroupInput struct { } type UpdateGroupInput struct { - Name string - Description *string - Platform string - RateMultiplier *float64 // 使用指针以支持设置为0 - IsExclusive *bool - Status string - SubscriptionType string // standard/subscription - DailyLimitUSD *float64 // 日限额 (USD) - WeeklyLimitUSD *float64 // 周限额 (USD) - MonthlyLimitUSD *float64 // 月限额 (USD) + Name string + Description *string + Platform string + RateMultiplier *float64 // 使用指针以支持设置为0 + IsExclusive *bool + Status string + SubscriptionType string // standard/subscription + DailyLimitUSD *float64 // 日限额 (USD) + WeeklyLimitUSD *float64 // 周限额 (USD) + MonthlyLimitUSD *float64 // 月限额 (USD) + LongContextPricingEnabled *bool + ModelPricing *[]ChannelModelPricing // 图片生成计费配置(仅 antigravity 平台使用) AllowImageGeneration *bool AllowBatchImageGeneration *bool diff --git a/backend/internal/service/billing_search_audio_cost_test.go b/backend/internal/service/billing_search_audio_cost_test.go index 94d2e5c659..ad3111f0dd 100644 --- a/backend/internal/service/billing_search_audio_cost_test.go +++ b/backend/internal/service/billing_search_audio_cost_test.go @@ -10,8 +10,8 @@ func TestCalculateSearchCost(t *testing.T) { t.Parallel() s := &BillingService{} require.Equal(t, 0.0, s.CalculateSearchCost(0, floatPtr(10), 1).ActualCost) - // nil price → default $10/1k: 5 calls = 0.05 - require.InDelta(t, 0.05, s.CalculateSearchCost(5, nil, 1).ActualCost, 1e-9) + // nil price -> official xAI default $5/1k: 5 calls = 0.025 + require.InDelta(t, 0.025, s.CalculateSearchCost(5, nil, 1).ActualCost, 1e-9) // explicit 0 → free require.Equal(t, 0.0, s.CalculateSearchCost(5, floatPtr(0), 1).ActualCost) price := 10.0 @@ -30,10 +30,10 @@ func TestCalculateAudioCost(t *testing.T) { require.InDelta(t, 1.5, s.CalculateAudioCost("tts", 0.1, cfg, 1).ActualCost, 1e-9) require.InDelta(t, 0.25, s.CalculateAudioCost("stt", 0.5, cfg, 1).ActualCost, 1e-9) require.Equal(t, 0.0, s.CalculateAudioCost("unknown", 1, cfg, 1).ActualCost) - // nil config → defaults (realtime $0.10/min, tts $15/M, stt $0.36/hr) - require.InDelta(t, 0.10, s.CalculateAudioCost("realtime", 1, nil, 1).ActualCost, 1e-9) + // nil config -> official defaults (think-fast-1 $0.05/min, TTS $15/M, REST STT $0.10/hr) + require.InDelta(t, 0.05, s.CalculateAudioCost("realtime", 1, nil, 1).ActualCost, 1e-9) require.InDelta(t, 15.0, s.CalculateAudioCost("tts", 1, nil, 1).ActualCost, 1e-9) - require.InDelta(t, 0.36, s.CalculateAudioCost("stt", 1, nil, 1).ActualCost, 1e-9) + require.InDelta(t, 0.10, s.CalculateAudioCost("stt", 1, nil, 1).ActualCost, 1e-9) // explicit 0 → free zero := 0.0 require.Equal(t, 0.0, s.CalculateAudioCost("realtime", 1, &audioPriceConfig{RealtimePerMin: &zero}, 1).ActualCost) diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index 4f4fa8e0bd..1fbbe1cc5e 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -104,6 +104,7 @@ type ModelPricing struct { CacheCreation1hPrice float64 // 1小时缓存创建每token价格 (USD) SupportsCacheBreakdown bool // 是否支持详细的缓存分类 LongContextInputThreshold int // 超过阈值后按整次会话提升输入价格 + LongContextThresholdInclusive bool // 达到阈值即应用(xAI);默认保持严格大于以兼容既有模型 LongContextInputMultiplier float64 // 长上下文整次会话输入倍率 LongContextOutputMultiplier float64 // 长上下文整次会话输出倍率 ImageOutputPricePerToken float64 // 图片输出 token 价格 (USD) @@ -585,45 +586,56 @@ func (s *BillingService) initFallbackPricing() { SupportsCacheBreakdown: false, } - // xAI Grok 4.5 (official docs: $2 input / $0.50 cached input / $6 output per MTok) + // xAI Grok 4.5: $2 input / $0.30 cached input / $6 output below 200k. s.fallbackPrices["grok-4.5"] = &ModelPricing{ - InputPricePerToken: 2e-6, - OutputPricePerToken: 6e-6, - CacheReadPricePerToken: 0.5e-6, - SupportsCacheBreakdown: false, + InputPricePerToken: 2e-6, + OutputPricePerToken: 6e-6, + CacheReadPricePerToken: 0.3e-6, + SupportsCacheBreakdown: false, + LongContextInputThreshold: 200000, + LongContextThresholdInclusive: true, + LongContextInputMultiplier: 2, + LongContextOutputMultiplier: 2, } // xAI Grok 4.6 (docs.x.ai/developers/models: $2 input / $0.50 cached input / // $6 output per MTok under 200k prompt tokens; ≥200k is 2× on input, // cached input, and output). s.fallbackPrices["grok-4.6"] = &ModelPricing{ - InputPricePerToken: 2e-6, - OutputPricePerToken: 6e-6, - CacheReadPricePerToken: 0.5e-6, - SupportsCacheBreakdown: false, - LongContextInputThreshold: 200000, - LongContextInputMultiplier: 2, - LongContextOutputMultiplier: 2, + InputPricePerToken: 2e-6, + OutputPricePerToken: 6e-6, + CacheReadPricePerToken: 0.5e-6, + SupportsCacheBreakdown: false, + LongContextInputThreshold: 200000, + LongContextThresholdInclusive: true, + LongContextInputMultiplier: 2, + LongContextOutputMultiplier: 2, } - // xAI Grok 4.3 (official docs: $1.25 input / $2.50 output per MTok) + // xAI Grok 4.3: $1.25 input / $0.20 cached / $2.50 output below 200k. s.fallbackPrices["grok-4.3"] = &ModelPricing{ - InputPricePerToken: 1.25e-6, - OutputPricePerToken: 2.5e-6, - CacheReadPricePerToken: 0.2e-6, - SupportsCacheBreakdown: false, - LongContextInputThreshold: 1000000, - LongContextInputMultiplier: 1, + InputPricePerToken: 1.25e-6, + OutputPricePerToken: 2.5e-6, + CacheReadPricePerToken: 0.2e-6, + SupportsCacheBreakdown: false, + LongContextInputThreshold: 200000, + LongContextThresholdInclusive: true, + LongContextInputMultiplier: 2, + LongContextOutputMultiplier: 2, } // xAI Grok Build 0.1 (official docs: $1 input / $0.20 cached input / // $2 output per MTok). Composer is available only through Grok Build and // has no standalone public API rate card, so its aliases use this coding // model rate instead of silently billing at zero. s.fallbackPrices["grok-build-0.1"] = &ModelPricing{ - InputPricePerToken: 1e-6, - OutputPricePerToken: 2e-6, - CacheReadPricePerToken: 0.2e-6, - SupportsCacheBreakdown: false, + InputPricePerToken: 1e-6, + OutputPricePerToken: 2e-6, + CacheReadPricePerToken: 0.2e-6, + SupportsCacheBreakdown: false, + LongContextInputThreshold: 200000, + LongContextThresholdInclusive: true, + LongContextInputMultiplier: 2, + LongContextOutputMultiplier: 2, } } @@ -816,7 +828,7 @@ func (s *BillingService) getFallbackPricing(model string) *ModelPricing { } switch modelLower { - case "grok", "grok-latest", "grok-4.5", "grok-4.5-latest", "grok-build-latest": + case "grok", "grok-latest", "grok-4.5", "grok-4.5-latest": return s.fallbackPrices["grok-4.5"] case "grok-4.6", "grok-4.6-latest": return s.fallbackPrices["grok-4.6"] @@ -827,7 +839,7 @@ func (s *BillingService) getFallbackPricing(model string) *ModelPricing { "grok-4.20-reasoning", "grok-4.20-non-reasoning": return s.fallbackPrices["grok-4.3"] - case "grok-build", "grok-build-0.1", "grok-composer", "grok-composer-2.5-fast", "composer-2.5": + case "grok-build", "grok-build-latest", "grok-build-0.1", "grok-composer", "grok-composer-2.5-fast", "composer-2.5": return s.fallbackPrices["grok-build-0.1"] } @@ -965,9 +977,11 @@ type CostInput struct { Ctx context.Context Model string GroupID *int64 // 用于渠道定价查找 + Group *Group Tokens UsageTokens - RequestCount int // 按次计费时使用 - SizeTier string // 按次/图片模式的层级标签("1K","2K","4K","HD" 等) + RequestCount int // 按次计费时使用 + UsageUnits float64 // 音频等连续计量单位(分钟/小时/百万字符) + SizeTier string // 按次/图片模式的层级标签("1K","2K","4K","HD" 等) RateMultiplier float64 ServiceTier string // "priority","flex","" 等 Resolver *ModelPricingResolver // 定价解析器 @@ -1000,6 +1014,7 @@ func (s *BillingService) CalculateCostUnified(input CostInput) (*CostBreakdown, resolved = input.Resolver.Resolve(input.Ctx, PricingInput{ Model: input.Model, GroupID: input.GroupID, + Group: input.Group, }) } @@ -1011,7 +1026,7 @@ func (s *BillingService) CalculateCostUnified(input CostInput) (*CostBreakdown, var breakdown *CostBreakdown var err error switch resolved.Mode { - case BillingModePerRequest, BillingModeImage: + case BillingModePerRequest, BillingModeImage, BillingModeVideo: breakdown, err = s.calculatePerRequestCost(resolved, input) default: // BillingModeToken breakdown, err = s.calculateTokenCost(resolved, input) @@ -1037,7 +1052,7 @@ func (s *BillingService) calculateTokenCost(resolved *ResolvedPricing, input Cos pricing = s.applyModelSpecificPricingPolicy(input.Model, pricing) // 长上下文定价仅在无区间定价时应用(区间定价已包含上下文分层) - applyLongCtx := len(resolved.Intervals) == 0 + applyLongCtx := len(resolved.Intervals) == 0 && resolved.longContextPricingEnabled if input.LongContextBillingEnabled != nil { applyLongCtx = applyLongCtx && *input.LongContextBillingEnabled } @@ -1172,9 +1187,13 @@ func (s *BillingService) computeCacheCreationCost(pricing *ModelPricing, tokens // calculatePerRequestCost 按次/图片计费 func (s *BillingService) calculatePerRequestCost(resolved *ResolvedPricing, input CostInput) (*CostBreakdown, error) { - count := input.RequestCount - if count <= 0 { - count = 1 + units := input.UsageUnits + if units <= 0 { + count := input.RequestCount + if count <= 0 { + count = 1 + } + units = float64(count) } var unitPrice float64 @@ -1193,7 +1212,7 @@ func (s *BillingService) calculatePerRequestCost(resolved *ResolvedPricing, inpu unitPrice = resolved.DefaultPerRequestPrice } - totalCost := unitPrice * float64(count) + totalCost := unitPrice * units actualCost := totalCost * input.RateMultiplier return &CostBreakdown{ @@ -1295,6 +1314,9 @@ func (s *BillingService) shouldApplySessionLongContextPricing(tokens UsageTokens return false } totalInputTokens := tokens.InputTokens + tokens.CacheCreationTokens + tokens.CacheReadTokens + if pricing.LongContextThresholdInclusive { + return totalInputTokens >= pricing.LongContextInputThreshold + } return totalInputTokens > pricing.LongContextInputThreshold } @@ -1466,6 +1488,8 @@ const ( defaultGrokImagineImagePrice2K = 0.02 defaultGrokImagineImageQualityPrice1K = 0.05 defaultGrokImagineImageQualityPrice2K = 0.07 + defaultGrokImagineImage20Price1K = 0.06 // default quality is Medium + defaultGrokImagineImage20Price2K = 0.08 // 视频默认价为 xAI 官方**每秒**输出价格(USD/s),总价 = 每秒价 × 时长(秒)。 defaultGrokImagineVideoPrice480P = 0.05 @@ -1477,14 +1501,14 @@ const ( // Codex alpha/search 网页搜索单次默认价:OpenAI 官方 web search 定价 $10/1000 次。 defaultWebSearchPricePerCall = 0.01 - // Grok /v1/web_search 与 SearchCount 附加费:与 Codex 对齐 $10/1000 次(按 1k 计价字段存储)。 - defaultSearchPricePer1k = 10.0 + // xAI server-side web/X search and code execution are $5/1000 calls. + defaultSearchPricePer1k = 5.0 - // Grok Voice 默认价(分组列 NULL 时使用;显式配 0 表示免费)。 - // 保守运营占位,运维可通过 groups.audio_* 覆盖。 - defaultAudioRealtimePricePerMin = 0.10 + // Generic realtime defaults to think-fast-1.0; think-fast-2.0 can be + // configured independently through per-model group/channel pricing. + defaultAudioRealtimePricePerMin = 0.05 defaultAudioTTSPricePerMillionChars = 15.0 - defaultAudioSTTPricePerHour = 0.36 + defaultAudioSTTPricePerHour = 0.10 ) // CalculateWebSearchCost 计算 Codex alpha/search 网页搜索按次费用。 @@ -1742,6 +1766,12 @@ func (s *BillingService) getDefaultVideoPrice(model string, resolution string) f func getDefaultGrokImagineImagePrice(model string, imageSize string) (float64, bool) { model = strings.ToLower(strings.TrimSpace(model)) switch model { + case "grok-imagine-image-2.0": + return getGrokImagineImageTierPrice( + imageSize, + defaultGrokImagineImage20Price1K, + defaultGrokImagineImage20Price2K, + ), true case "grok-imagine-image-quality": return getGrokImagineImageTierPrice( imageSize, diff --git a/backend/internal/service/billing_service_test.go b/backend/internal/service/billing_service_test.go index 6a30f6eeaf..30a91fda2a 100644 --- a/backend/internal/service/billing_service_test.go +++ b/backend/internal/service/billing_service_test.go @@ -1153,14 +1153,14 @@ func TestCalculateCostWithLongContext_PropagatesError(t *testing.T) { func TestGetModelPricing_Grok45OfficialFallback(t *testing.T) { svc := newTestBillingService() - for _, model := range []string{"grok", "grok-latest", "grok-4.5", "grok-4.5-latest", "grok-build-latest"} { + for _, model := range []string{"grok", "grok-latest", "grok-4.5", "grok-4.5-latest"} { model := model t.Run(model, func(t *testing.T) { pricing, err := svc.GetModelPricing(model) require.NoError(t, err) require.InDelta(t, 2e-6, pricing.InputPricePerToken, 1e-12) require.InDelta(t, 6e-6, pricing.OutputPricePerToken, 1e-12) - require.InDelta(t, 0.5e-6, pricing.CacheReadPricePerToken, 1e-12) + require.InDelta(t, 0.3e-6, pricing.CacheReadPricePerToken, 1e-12) require.False(t, pricing.SupportsCacheBreakdown) }) } diff --git a/backend/internal/service/channel.go b/backend/internal/service/channel.go index 5ed834eeca..4d5523b0a3 100644 --- a/backend/internal/service/channel.go +++ b/backend/internal/service/channel.go @@ -20,7 +20,7 @@ const ( // IsValid 检查 BillingMode 是否为合法值 func (m BillingMode) IsValid() bool { switch m { - case BillingModeToken, BillingModePerRequest, BillingModeImage, "": + case BillingModeToken, BillingModePerRequest, BillingModeImage, BillingModeVideo, "": return true } return false @@ -87,38 +87,38 @@ type AccountStatsPricingRule struct { // ChannelModelPricing 渠道模型定价条目 type ChannelModelPricing struct { - ID int64 - ChannelID int64 - Platform string // 所属平台(anthropic/openai/gemini/...) - Models []string // 绑定的模型列表 - BillingMode BillingMode // 计费模式 - InputPrice *float64 // 每 token 输入价格(USD)— 向后兼容 flat 定价 - OutputPrice *float64 // 每 token 输出价格(USD) - CacheWritePrice *float64 // 缓存写入价格 - CacheReadPrice *float64 // 缓存读取价格 - ImageInputPrice *float64 // 图片输入 token 价格(如 gpt-image-2 图片编辑);未配置时回退文本输入价 - ImageOutputPrice *float64 // 图片输出价格(向后兼容) - PerRequestPrice *float64 // 默认按次计费价格(USD) - Intervals []PricingInterval // 区间定价列表 - CreatedAt time.Time - UpdatedAt time.Time + ID int64 `json:"id,omitempty"` + ChannelID int64 `json:"channel_id,omitempty"` + Platform string `json:"platform"` // 所属平台(anthropic/openai/gemini/...) + Models []string `json:"models"` + BillingMode BillingMode `json:"billing_mode"` + InputPrice *float64 `json:"input_price"` + OutputPrice *float64 `json:"output_price"` + CacheWritePrice *float64 `json:"cache_write_price"` + CacheReadPrice *float64 `json:"cache_read_price"` + ImageInputPrice *float64 `json:"image_input_price"` + ImageOutputPrice *float64 `json:"image_output_price"` + PerRequestPrice *float64 `json:"per_request_price"` + Intervals []PricingInterval `json:"intervals"` + CreatedAt time.Time `json:"created_at,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty"` } // PricingInterval 定价区间(token 区间 / 按次分层 / 图片分辨率分层) type PricingInterval struct { - ID int64 - PricingID int64 - MinTokens int // 区间下界(含) - MaxTokens *int // 区间上界(不含),nil = 无上限 - TierLabel string // 层级标签(按次/图片模式:1K, 2K, 4K, HD 等) - InputPrice *float64 // token 模式:每 token 输入价 - OutputPrice *float64 // token 模式:每 token 输出价 - CacheWritePrice *float64 // token 模式:缓存写入价 - CacheReadPrice *float64 // token 模式:缓存读取价 - PerRequestPrice *float64 // 按次/图片模式:每次请求价格 - SortOrder int - CreatedAt time.Time - UpdatedAt time.Time + ID int64 `json:"id,omitempty"` + PricingID int64 `json:"pricing_id,omitempty"` + MinTokens int `json:"min_tokens"` + MaxTokens *int `json:"max_tokens"` + TierLabel string `json:"tier_label"` + InputPrice *float64 `json:"input_price"` + OutputPrice *float64 `json:"output_price"` + CacheWritePrice *float64 `json:"cache_write_price"` + CacheReadPrice *float64 `json:"cache_read_price"` + PerRequestPrice *float64 `json:"per_request_price"` + SortOrder int `json:"sort_order"` + CreatedAt time.Time `json:"created_at,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty"` } // IsActive 判断渠道是否启用 @@ -315,7 +315,7 @@ func ValidateIntervals(intervals []PricingInterval, mode BillingMode) error { } // per_request / image 模式按 tier_label 匹配,不做 token 区间重叠校验 - if mode == BillingModePerRequest || mode == BillingModeImage { + if mode == BillingModePerRequest || mode == BillingModeImage || mode == BillingModeVideo { return nil } return validateIntervalOverlap(sorted) diff --git a/backend/internal/service/channel_service.go b/backend/internal/service/channel_service.go index e56f699a8e..a7e9bffe31 100644 --- a/backend/internal/service/channel_service.go +++ b/backend/internal/service/channel_service.go @@ -659,7 +659,7 @@ func validatePricingBillingMode(pricing []ChannelModelPricing) error { } func checkBillingModeRequirements(p ChannelModelPricing) error { - if p.BillingMode == BillingModePerRequest || p.BillingMode == BillingModeImage { + if p.BillingMode == BillingModePerRequest || p.BillingMode == BillingModeImage || p.BillingMode == BillingModeVideo { if p.PerRequestPrice == nil && len(p.Intervals) == 0 { return infraerrors.BadRequest( "BILLING_MODE_MISSING_PRICE", diff --git a/backend/internal/service/gateway_usage_billing.go b/backend/internal/service/gateway_usage_billing.go index a5998f3595..d28a1be19a 100644 --- a/backend/internal/service/gateway_usage_billing.go +++ b/backend/internal/service/gateway_usage_billing.go @@ -951,6 +951,18 @@ func (s *GatewayService) calculateRecordUsageCost( // Voice audio (TTS / STT / realtime) when present on the forward result. if result.AudioUsage != nil { + if resolved := s.resolveChannelPricing(ctx, billingModel, apiKey); resolved != nil && + resolved.Mode == BillingModePerRequest { + gid := apiKey.Group.ID + cost, err := s.billingService.CalculateCostUnified(CostInput{ + Ctx: ctx, Model: billingModel, GroupID: &gid, Group: apiKey.Group, + UsageUnits: result.AudioUsage.DurationOrUnits, SizeTier: result.AudioUsage.Mode, + RateMultiplier: multiplier, Resolver: s.resolver, Resolved: resolved, + }) + if err == nil { + return cost + } + } cfg := groupAudioPriceConfigFromAPIKey(apiKey) return s.billingService.CalculateAudioCost(result.AudioUsage.Mode, result.AudioUsage.DurationOrUnits, cfg, multiplier) } @@ -1047,8 +1059,8 @@ func (s *GatewayService) resolveChannelPricing(ctx context.Context, billingModel return nil } gid := apiKey.Group.ID - resolved := s.resolver.Resolve(ctx, PricingInput{Model: billingModel, GroupID: &gid}) - if resolved.Source == PricingSourceChannel { + resolved := s.resolver.Resolve(ctx, PricingInput{Model: billingModel, GroupID: &gid, Group: apiKey.Group}) + if resolved.Source == PricingSourceGroup || resolved.Source == PricingSourceChannel { return resolved } return nil @@ -1063,11 +1075,23 @@ func (s *GatewayService) calculateImageCost( multiplier float64, ) *CostBreakdown { sizeTier := NormalizeImageBillingTierOrDefault(result.ImageSize) + resolved := s.resolveChannelPricing(ctx, billingModel, apiKey) + if resolved != nil && resolved.Source == PricingSourceGroup { + gid := apiKey.Group.ID + cost, err := s.billingService.CalculateCostUnified(CostInput{ + Ctx: ctx, Model: billingModel, GroupID: &gid, Group: apiKey.Group, + RequestCount: result.ImageCount, SizeTier: sizeTier, + RateMultiplier: multiplier, Resolver: s.resolver, Resolved: resolved, + }) + if err == nil { + return cost + } + } groupConfig := imagePriceConfigFromAPIKey(apiKey) if apiKeyHasConfiguredImagePrice(apiKey, sizeTier) { return s.billingService.CalculateImageCost(billingModel, sizeTier, result.ImageCount, groupConfig, multiplier) } - if resolved := s.resolveChannelPricing(ctx, billingModel, apiKey); resolved != nil { + if resolved != nil && resolved.Source == PricingSourceChannel { tokens := UsageTokens{ InputTokens: result.Usage.InputTokens, OutputTokens: result.Usage.OutputTokens, @@ -1078,6 +1102,7 @@ func (s *GatewayService) calculateImageCost( Ctx: ctx, Model: billingModel, GroupID: &gid, + Group: apiKey.Group, Tokens: tokens, RequestCount: result.ImageCount, SizeTier: sizeTier, @@ -1117,22 +1142,30 @@ func (s *GatewayService) calculateTokenCost( var cost *CostBreakdown var err error - // 优先尝试渠道定价 → CalculateCostUnified + // Explicit group/channel pricing wins. Built-in pricing also uses the unified + // resolver so the group long-context toggle can veto model-native tiers. if resolved := s.resolveChannelPricing(ctx, billingModel, apiKey); resolved != nil { gid := apiKey.Group.ID cost, err = s.billingService.CalculateCostUnified(CostInput{ Ctx: ctx, Model: billingModel, GroupID: &gid, + Group: apiKey.Group, Tokens: tokens, RequestCount: 1, RateMultiplier: multiplier, Resolver: s.resolver, Resolved: resolved, }) - } else if opts.LongContextThreshold > 0 { + } else if opts.LongContextThreshold > 0 && (apiKey.Group == nil || apiKey.Group.LongContextPricingEnabled) { // 长上下文双倍计费(如 Gemini 200K 阈值) cost, err = s.billingService.CalculateCostWithLongContext(billingModel, tokens, multiplier, opts.LongContextThreshold, opts.LongContextMultiplier) + } else if s.resolver != nil && apiKey.Group != nil { + gid := apiKey.Group.ID + cost, err = s.billingService.CalculateCostUnified(CostInput{ + Ctx: ctx, Model: billingModel, GroupID: &gid, Group: apiKey.Group, + Tokens: tokens, RequestCount: 1, RateMultiplier: multiplier, Resolver: s.resolver, + }) } else { cost, err = s.billingService.CalculateCost(billingModel, tokens, multiplier) } diff --git a/backend/internal/service/group.go b/backend/internal/service/group.go index ddde276e22..bf0440afc3 100644 --- a/backend/internal/service/group.go +++ b/backend/internal/service/group.go @@ -70,6 +70,11 @@ type Group struct { AudioTTSPricePerMillionChars *float64 AudioSTTPricePerHour *float64 + // ModelPricing overrides channel and built-in prices for matching models. + // Token intervals are selected only when LongContextPricingEnabled is true. + LongContextPricingEnabled bool + ModelPricing []ChannelModelPricing + // Claude Code 客户端限制 ClaudeCodeOnly bool FallbackGroupID *int64 diff --git a/backend/internal/service/model_pricing_resolver.go b/backend/internal/service/model_pricing_resolver.go index ea5f4a29d1..efee77e3e9 100644 --- a/backend/internal/service/model_pricing_resolver.go +++ b/backend/internal/service/model_pricing_resolver.go @@ -3,10 +3,12 @@ package service import ( "context" "log/slog" + "strings" ) // PricingSource 定价来源标识 const ( + PricingSourceGroup = "group" PricingSourceChannel = "channel" PricingSourceLiteLLM = "litellm" PricingSourceFallback = "fallback" @@ -37,10 +39,12 @@ type ResolvedPricing struct { // 渠道定价原始配置(用于区间模式下获取 ImageOutputPrice) channelPricing *ChannelModelPricing + + longContextPricingEnabled bool } // ModelPricingResolver 统一模型定价解析器。 -// 解析链:Channel → LiteLLM → Fallback。 +// 解析链:Group → Channel → LiteLLM → Fallback。 type ModelPricingResolver struct { channelService *ChannelService billingService *BillingService @@ -58,12 +62,23 @@ func NewModelPricingResolver(channelService *ChannelService, billingService *Bil type PricingInput struct { Model string GroupID *int64 // nil 表示不检查渠道 + Group *Group } // Resolve 解析模型定价。 // 1. 获取基础定价(LiteLLM → Fallback) // 2. 如果指定了 GroupID,查找渠道定价并覆盖 func (r *ModelPricingResolver) Resolve(ctx context.Context, input PricingInput) *ResolvedPricing { + longContextPricingEnabled := input.Group == nil || input.Group.LongContextPricingEnabled + if groupPricing := matchGroupModelPricing(input.Group, input.Model); groupPricing != nil { + resolved := r.resolveConfiguredPricing(groupPricing, input.Model, PricingSourceGroup) + resolved.longContextPricingEnabled = longContextPricingEnabled + if resolved.Mode == BillingModeToken && !longContextPricingEnabled { + r.applyFirstTokenTier(resolved, groupPricing) + } + return resolved + } + var chPricing *ChannelModelPricing if input.GroupID != nil && r.channelService != nil { chPricing = r.channelService.GetChannelModelPricing(ctx, *input.GroupID, input.Model) @@ -72,12 +87,13 @@ func (r *ModelPricingResolver) Resolve(ctx context.Context, input PricingInput) if mode == "" { mode = BillingModeToken } - if mode == BillingModePerRequest || mode == BillingModeImage { + if mode == BillingModePerRequest || mode == BillingModeImage || mode == BillingModeVideo { resolved := &ResolvedPricing{ Mode: mode, Source: PricingSourceChannel, channelPricing: chPricing, } + resolved.longContextPricingEnabled = longContextPricingEnabled r.applyRequestTierOverrides(chPricing, resolved) return resolved } @@ -93,19 +109,79 @@ func (r *ModelPricingResolver) Resolve(ctx context.Context, input PricingInput) Source: source, SupportsCacheBreakdown: basePricing != nil && basePricing.SupportsCacheBreakdown, } + resolved.longContextPricingEnabled = longContextPricingEnabled // 2. 如果有 GroupID,尝试渠道覆盖 if chPricing != nil { resolved.Source = PricingSourceChannel resolved.channelPricing = chPricing r.applyTokenOverrides(chPricing, resolved) + if !longContextPricingEnabled { + r.applyFirstTokenTier(resolved, chPricing) + } } else if input.GroupID != nil { r.applyChannelOverrides(ctx, *input.GroupID, input.Model, resolved) + if resolved.Source == PricingSourceChannel && !longContextPricingEnabled { + r.applyFirstTokenTier(resolved, resolved.channelPricing) + } } return resolved } +func (r *ModelPricingResolver) resolveConfiguredPricing(config *ChannelModelPricing, model, source string) *ResolvedPricing { + mode := config.BillingMode + if mode == "" { + mode = BillingModeToken + } + resolved := &ResolvedPricing{Mode: mode, Source: source, channelPricing: config} + if mode == BillingModePerRequest || mode == BillingModeImage || mode == BillingModeVideo { + r.applyRequestTierOverrides(config, resolved) + return resolved + } + resolved.BasePricing, _ = r.resolveBasePricing(model) + resolved.SupportsCacheBreakdown = resolved.BasePricing != nil && resolved.BasePricing.SupportsCacheBreakdown + r.applyTokenOverrides(config, resolved) + return resolved +} + +func matchGroupModelPricing(group *Group, model string) *ChannelModelPricing { + if group == nil { + return nil + } + model = normalizeChannelPricingModelName(model) + var wildcard *ChannelModelPricing + for i := range group.ModelPricing { + entry := &group.ModelPricing[i] + for _, pattern := range entry.Models { + normalized := normalizeChannelPricingModelName(pattern) + if normalized == model { + cp := entry.Clone() + return &cp + } + if strings.HasSuffix(normalized, "*") && strings.HasPrefix(model, strings.TrimSuffix(normalized, "*")) && wildcard == nil { + cp := entry.Clone() + wildcard = &cp + } + } + } + return wildcard +} + +func (r *ModelPricingResolver) applyFirstTokenTier(resolved *ResolvedPricing, config *ChannelModelPricing) { + if resolved == nil || len(resolved.Intervals) == 0 { + return + } + first := resolved.Intervals[0] + for _, interval := range resolved.Intervals[1:] { + if interval.MinTokens < first.MinTokens { + first = interval + } + } + resolved.BasePricing = intervalToModelPricing(&first, resolved.SupportsCacheBreakdown, config) + resolved.Intervals = nil +} + // resolveBasePricing 从 LiteLLM 或 Fallback 获取基础定价 func (r *ModelPricingResolver) resolveBasePricing(model string) (*ModelPricing, string) { pricing, err := r.billingService.GetModelPricing(model) @@ -134,7 +210,7 @@ func (r *ModelPricingResolver) applyChannelOverrides(ctx context.Context, groupI switch resolved.Mode { case BillingModeToken: r.applyTokenOverrides(chPricing, resolved) - case BillingModePerRequest, BillingModeImage: + case BillingModePerRequest, BillingModeImage, BillingModeVideo: r.applyRequestTierOverrides(chPricing, resolved) } } diff --git a/backend/internal/service/model_pricing_resolver_test.go b/backend/internal/service/model_pricing_resolver_test.go index 71ba9904c2..215d6f0ed1 100644 --- a/backend/internal/service/model_pricing_resolver_test.go +++ b/backend/internal/service/model_pricing_resolver_test.go @@ -833,3 +833,55 @@ func TestApplyTokenOverrides_IntervalDoesNotPolluteFallbackPrices(t *testing.T) require.InDelta(t, 15e-6, fp.OutputPricePerToken, 1e-12, "fallback OutputPricePerToken polluted") require.False(t, fp.ImageOutputPriceExplicit, "fallback ImageOutputPriceExplicit polluted") } + +func TestResolve_GroupPricingOverridesChannel(t *testing.T) { + r := newResolverWithChannel(t, []ChannelModelPricing{{ + Platform: "anthropic", Models: []string{"claude-sonnet-4"}, BillingMode: BillingModeToken, + InputPrice: testPtrFloat64(10e-6), OutputPrice: testPtrFloat64(20e-6), + }}) + group := &Group{ID: 100, ModelPricing: []ChannelModelPricing{{ + Models: []string{"claude-sonnet-*"}, BillingMode: BillingModeToken, + InputPrice: testPtrFloat64(1e-6), OutputPrice: testPtrFloat64(2e-6), + }}} + resolved := r.Resolve(context.Background(), PricingInput{Model: "claude-sonnet-4", GroupID: groupIDPtr(), Group: group}) + + require.Equal(t, PricingSourceGroup, resolved.Source) + require.InDelta(t, 1e-6, resolved.BasePricing.InputPricePerToken, 1e-12) + require.InDelta(t, 2e-6, resolved.BasePricing.OutputPricePerToken, 1e-12) +} + +func TestResolve_GroupLongContextToggleControlsIntervals(t *testing.T) { + r := NewModelPricingResolver(nil, newTestBillingServiceForResolver()) + group := &Group{ID: 100, ModelPricing: []ChannelModelPricing{{ + Models: []string{"claude-sonnet-4"}, BillingMode: BillingModeToken, + Intervals: []PricingInterval{ + {MinTokens: 0, MaxTokens: testPtrInt(200000), InputPrice: testPtrFloat64(1e-6)}, + {MinTokens: 200000, InputPrice: testPtrFloat64(2e-6)}, + }, + }}} + + resolved := r.Resolve(context.Background(), PricingInput{Model: "claude-sonnet-4", Group: group}) + require.Empty(t, resolved.Intervals) + require.InDelta(t, 1e-6, r.GetIntervalPricing(resolved, 300000).InputPricePerToken, 1e-12) + + group.LongContextPricingEnabled = true + resolved = r.Resolve(context.Background(), PricingInput{Model: "claude-sonnet-4", Group: group}) + require.Len(t, resolved.Intervals, 2) + require.InDelta(t, 2e-6, r.GetIntervalPricing(resolved, 300000).InputPricePerToken, 1e-12) +} + +func TestCalculateCostUnified_UsesContinuousMediaUnits(t *testing.T) { + bs := newTestBillingServiceForResolver() + r := NewModelPricingResolver(nil, bs) + price := 0.08 + group := &Group{ModelPricing: []ChannelModelPricing{{ + Models: []string{"grok-voice-think-fast-2.0"}, BillingMode: BillingModePerRequest, + PerRequestPrice: &price, + }}} + cost, err := bs.CalculateCostUnified(CostInput{ + Ctx: context.Background(), Model: "grok-voice-think-fast-2.0", Group: group, + UsageUnits: 1.5, RateMultiplier: 1, Resolver: r, + }) + require.NoError(t, err) + require.InDelta(t, 0.12, cost.TotalCost, 1e-12) +} diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go index c2de3c6472..a7691fe3e1 100644 --- a/backend/internal/service/openai_gateway_usage.go +++ b/backend/internal/service/openai_gateway_usage.go @@ -507,6 +507,15 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageCost( } } if result != nil && result.AudioUsage != nil { + if resolved := s.resolveOpenAIChannelPricing(ctx, billingModel, apiKey); resolved != nil && + (resolved.Mode == BillingModePerRequest) { + gid := apiKey.Group.ID + return s.billingService.CalculateCostUnified(CostInput{ + Ctx: ctx, Model: billingModel, GroupID: &gid, Group: apiKey.Group, + UsageUnits: result.AudioUsage.DurationOrUnits, SizeTier: result.AudioUsage.Mode, + RateMultiplier: webSearchMultiplier, Resolver: s.resolver, Resolved: resolved, + }) + } cfg := groupAudioPriceConfigFromAPIKey(apiKey) return s.billingService.CalculateAudioCost(result.AudioUsage.Mode, result.AudioUsage.DurationOrUnits, cfg, webSearchMultiplier), nil } @@ -628,15 +637,9 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageTokenCost( if s.resolver != nil && apiKey.Group != nil { gid := apiKey.Group.ID return s.billingService.CalculateCostUnified(CostInput{ - Ctx: ctx, - Model: billingModel, - GroupID: &gid, - Tokens: tokens, - RequestCount: 1, - RateMultiplier: multiplier, - ServiceTier: serviceTier, - Resolver: s.resolver, - LongContextBillingEnabled: &longContextBillingEnabled, + Ctx: ctx, Model: billingModel, GroupID: &gid, Group: apiKey.Group, + Tokens: tokens, RequestCount: 1, RateMultiplier: multiplier, + ServiceTier: serviceTier, Resolver: s.resolver, }) } return s.billingService.calculateCostWithServiceTierPolicy( @@ -656,6 +659,19 @@ func (s *OpenAIGatewayService) calculateOpenAIImageCost( multiplier float64, ) *CostBreakdown { sizeTier := NormalizeImageBillingTierOrDefault(result.ImageSize) + resolved := s.resolveOpenAIChannelPricing(ctx, billingModel, apiKey) + if resolved != nil && resolved.Source == PricingSourceGroup && + (resolved.Mode == BillingModePerRequest || resolved.Mode == BillingModeImage) { + gid := apiKey.Group.ID + cost, err := s.billingService.CalculateCostUnified(CostInput{ + Ctx: ctx, Model: billingModel, GroupID: &gid, Group: apiKey.Group, + RequestCount: result.ImageCount, SizeTier: sizeTier, + RateMultiplier: multiplier, Resolver: s.resolver, Resolved: resolved, + }) + if err == nil { + return cost + } + } groupConfig := imagePriceConfigFromAPIKey(apiKey) if apiKeyHasConfiguredImagePrice(apiKey, sizeTier) { return s.billingService.CalculateImageCost(billingModel, sizeTier, result.ImageCount, groupConfig, multiplier) @@ -667,13 +683,14 @@ func (s *OpenAIGatewayService) calculateOpenAIImageCost( return s.billingService.CalculateImageCost(billingModel, sizeTier, result.ImageCount, groupConfig, multiplier) } } - if resolved := s.resolveOpenAIChannelPricing(ctx, billingModel, apiKey); resolved != nil && + if resolved != nil && resolved.Source == PricingSourceChannel && (resolved.Mode == BillingModePerRequest || resolved.Mode == BillingModeImage) { gid := apiKey.Group.ID cost, err := s.billingService.CalculateCostUnified(CostInput{ Ctx: ctx, Model: billingModel, GroupID: &gid, + Group: apiKey.Group, RequestCount: result.ImageCount, SizeTier: sizeTier, RateMultiplier: multiplier, @@ -702,6 +719,18 @@ func (s *OpenAIGatewayService) calculateOpenAIVideoCost( } resolution := NormalizeVideoBillingResolutionOrDefault(result.VideoResolution) durationSeconds := NormalizeVideoBillingDurationSecondsOrDefault(result.VideoDurationSeconds) + resolved := s.resolveOpenAIChannelPricing(ctx, billingModel, apiKey) + if resolved != nil && resolved.Source == PricingSourceGroup && resolved.Mode == BillingModeVideo { + gid := apiKey.Group.ID + cost, err := s.billingService.CalculateCostUnified(CostInput{ + Ctx: ctx, Model: billingModel, GroupID: &gid, Group: apiKey.Group, + UsageUnits: float64(videoCount * durationSeconds), SizeTier: resolution, + RateMultiplier: multiplier, Resolver: s.resolver, Resolved: resolved, + }) + if err == nil { + return cost + } + } groupConfig := videoPriceConfigFromAPIKey(apiKey) if apiKeyHasConfiguredVideoPrice(apiKey, billingModel, resolution) { return s.billingService.CalculateVideoCost(billingModel, resolution, videoCount, durationSeconds, groupConfig, multiplier) @@ -713,15 +742,21 @@ func (s *OpenAIGatewayService) calculateOpenAIVideoCost( return s.billingService.CalculateVideoCost(billingModel, resolution, videoCount, durationSeconds, groupConfig, multiplier) } } - if resolved := s.resolveOpenAIChannelPricing(ctx, billingModel, apiKey); resolved != nil && - (resolved.Mode == BillingModePerRequest || resolved.Mode == BillingModeImage) { + if resolved != nil && resolved.Source == PricingSourceChannel && + (resolved.Mode == BillingModePerRequest || resolved.Mode == BillingModeImage || resolved.Mode == BillingModeVideo) { // 渠道 per_request/image 定价保持"按请求次数"口径(价格由管理员按次配置),不乘视频时长。 gid := apiKey.Group.ID + units := float64(videoCount) + if resolved.Mode == BillingModeVideo { + units = float64(videoCount * durationSeconds) + } cost, err := s.billingService.CalculateCostUnified(CostInput{ Ctx: ctx, Model: billingModel, GroupID: &gid, + Group: apiKey.Group, RequestCount: videoCount, + UsageUnits: units, SizeTier: resolution, RateMultiplier: multiplier, Resolver: s.resolver, @@ -778,6 +813,9 @@ func groupMediaPricingLooksIncomplete(group *Group) bool { if len(group.VideoModelPrices) > 0 { return false } + if len(group.ModelPricing) > 0 || group.LongContextPricingEnabled { + return false + } if group.SearchPricePer1k != nil || group.AudioRealtimePricePerMin != nil || group.AudioTTSPricePerMillionChars != nil || @@ -794,8 +832,8 @@ func (s *OpenAIGatewayService) resolveOpenAIChannelPricing(ctx context.Context, return nil } gid := apiKey.Group.ID - resolved := s.resolver.Resolve(ctx, PricingInput{Model: billingModel, GroupID: &gid}) - if resolved.Source == PricingSourceChannel { + resolved := s.resolver.Resolve(ctx, PricingInput{Model: billingModel, GroupID: &gid, Group: apiKey.Group}) + if resolved.Source == PricingSourceGroup || resolved.Source == PricingSourceChannel { return resolved } return nil diff --git a/backend/migrations/221_group_model_pricing.sql b/backend/migrations/221_group_model_pricing.sql new file mode 100644 index 0000000000..47498a4e47 --- /dev/null +++ b/backend/migrations/221_group_model_pricing.sql @@ -0,0 +1,8 @@ +ALTER TABLE groups + ADD COLUMN IF NOT EXISTS long_context_pricing_enabled BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS model_pricing JSONB; + +COMMENT ON COLUMN groups.long_context_pricing_enabled IS + 'Whether token pricing selects long-context tiers; false uses the lowest tier only'; +COMMENT ON COLUMN groups.model_pricing IS + 'Per-model group pricing overrides channel and built-in model pricing'; diff --git a/frontend/src/components/admin/channel/PricingEntryCard.vue b/frontend/src/components/admin/channel/PricingEntryCard.vue index 086a9cd307..abb10a9a4d 100644 --- a/frontend/src/components/admin/channel/PricingEntryCard.vue +++ b/frontend/src/components/admin/channel/PricingEntryCard.vue @@ -194,11 +194,11 @@ - -
+ +
@@ -209,9 +209,9 @@
-
@@ -261,7 +261,8 @@ const collapsed = ref(props.entry.models.length > 0) const billingModeOptions = computed(() => [ { value: 'token', label: t('admin.channels.billingMode.token') }, { value: 'per_request', label: t('admin.channels.billingMode.perRequest') }, - { value: 'image', label: t('admin.channels.billingMode.image') } + { value: 'image', label: t('admin.channels.billingMode.image') }, + { value: 'video', label: t('admin.channels.billingMode.video') } ]) const billingModeLabel = computed(() => { @@ -284,9 +285,11 @@ function addInterval() { emit('update', { ...props.entry, intervals }) } -function addImageTier() { +function addMediaTier() { const intervals = [...(props.entry.intervals || [])] - const labels = ['1K', '2K', '4K', 'HD'] + const labels = props.entry.billing_mode === 'video' + ? ['480p', '720p', '1080p'] + : ['1K', '2K', '4K', 'HD'] intervals.push({ min_tokens: 0, max_tokens: null, tier_label: labels[intervals.length] || '', input_price: null, output_price: null, cache_write_price: null, diff --git a/frontend/src/constants/channel.ts b/frontend/src/constants/channel.ts index 6b54b47df0..1e1b9205de 100644 --- a/frontend/src/constants/channel.ts +++ b/frontend/src/constants/channel.ts @@ -7,10 +7,12 @@ export type ChannelStatus = typeof CHANNEL_STATUS_ACTIVE | typeof CHANNEL_STATUS export const BILLING_MODE_TOKEN = 'token' as const export const BILLING_MODE_PER_REQUEST = 'per_request' as const export const BILLING_MODE_IMAGE = 'image' as const +export const BILLING_MODE_VIDEO = 'video' as const export type BillingMode = | typeof BILLING_MODE_TOKEN | typeof BILLING_MODE_PER_REQUEST | typeof BILLING_MODE_IMAGE + | typeof BILLING_MODE_VIDEO /** Billing-model-source values (must match service.BillingModelSource* constants in Go). */ export const BILLING_MODEL_SOURCE_REQUESTED = 'requested' as const diff --git a/frontend/src/i18n/locales/en/admin/channels.ts b/frontend/src/i18n/locales/en/admin/channels.ts index 6b26e21da9..d68ea5060d 100644 --- a/frontend/src/i18n/locales/en/admin/channels.ts +++ b/frontend/src/i18n/locales/en/admin/channels.ts @@ -93,7 +93,8 @@ export default { billingMode: { token: 'Token', perRequest: 'Per Request', - image: 'Image (Per Request)' + image: 'Image (Per Request)', + video: 'Video (Per Second)' }, form: { name: 'Name', @@ -127,6 +128,7 @@ export default { addInterval: 'Add Interval', requestTiers: 'Request Tiers', imageTiers: 'Image Tiers (Per Request)', + videoTiers: 'Video Resolution Tiers (Per Second)', addTier: 'Add Tier', noTiersYet: 'No tiers yet. Click add to configure per-request pricing.', noPricingRules: 'No pricing rules yet. Click "Add" to create one.', @@ -152,6 +154,7 @@ export default { restrictModelsHint: 'When enabled, only models in the pricing list are allowed. Others will be rejected.', defaultPerRequestPrice: 'Default per-request price (fallback when no tier matches)', defaultImagePrice: 'Default image price (fallback when no tier matches)', + defaultVideoPrice: 'Default video price per second (fallback when no tier matches)', platformConfig: 'Platform Configuration', webSearchEmulation: 'Web Search Emulation', webSearchEmulationHint: '⚠️ When enabled, all accounts in this channel\'s Anthropic groups will intercept web_search requests. Use with caution.', diff --git a/frontend/src/i18n/locales/en/admin/overview.ts b/frontend/src/i18n/locales/en/admin/overview.ts index f626b6edc6..832b586475 100644 --- a/frontend/src/i18n/locales/en/admin/overview.ts +++ b/frontend/src/i18n/locales/en/admin/overview.ts @@ -1005,6 +1005,13 @@ export default { searchPricePer1k: 'Search price per 1k calls (USD)', pricePlaceholder: 'optional' }, + modelPricing: { + title: 'Per-model group pricing', + description: 'Overrides channel and built-in prices for matching models. Use per-request tiers such as realtime, tts, and stt for audio.', + longContext: 'Enable long-context tier pricing', + longContextHint: 'When disabled, token models always use the lowest configured tier.', + add: 'Add model price' + }, voicePricing: { title: 'Grok Voice Pricing', description: 'Optional per-group prices for Voice realtime / TTS / STT (USD). Leave empty to leave unpriced.', diff --git a/frontend/src/i18n/locales/zh/admin/channels.ts b/frontend/src/i18n/locales/zh/admin/channels.ts index 3da00a3120..808e78fa88 100644 --- a/frontend/src/i18n/locales/zh/admin/channels.ts +++ b/frontend/src/i18n/locales/zh/admin/channels.ts @@ -93,7 +93,8 @@ export default { billingMode: { token: 'Token', perRequest: '按次', - image: '图片(按次)' + image: '图片(按次)', + video: '视频(按秒)' }, form: { name: '名称', @@ -127,6 +128,7 @@ export default { addInterval: '添加区间', requestTiers: '按次计费层级', imageTiers: '图片计费层级(按次)', + videoTiers: '视频分辨率层级(按秒)', addTier: '添加层级', noTiersYet: '暂无层级,点击添加配置按次计费价格', noPricingRules: '暂无定价规则,点击"添加"创建', @@ -152,6 +154,7 @@ export default { restrictModelsHint: '开启后,仅允许模型定价列表中的模型。不在列表中的模型请求将被拒绝。', defaultPerRequestPrice: '默认单次价格(未命中层级时使用)', defaultImagePrice: '默认图片价格(未命中层级时使用)', + defaultVideoPrice: '默认视频每秒价格(未命中层级时使用)', platformConfig: '平台配置', webSearchEmulation: 'Web Search 模拟', webSearchEmulationHint: '⚠️ 开启后该渠道下所有 Anthropic 分组的账号将自动拦截 web_search 请求,请谨慎操作', diff --git a/frontend/src/i18n/locales/zh/admin/overview.ts b/frontend/src/i18n/locales/zh/admin/overview.ts index c4ad9a542b..7f390c901e 100644 --- a/frontend/src/i18n/locales/zh/admin/overview.ts +++ b/frontend/src/i18n/locales/zh/admin/overview.ts @@ -1002,6 +1002,13 @@ export default { searchPricePer1k: '搜索每千次价格(USD)', pricePlaceholder: '可选' }, + modelPricing: { + title: '分组逐模型定价', + description: '匹配模型后覆盖渠道和内置价格。音频可用按次层级配置 realtime、tts、stt。', + longContext: '启用长上下文阶梯定价', + longContextHint: '关闭时,Token 模型始终只使用配置的最低档。', + add: '添加模型价格' + }, voicePricing: { title: 'Grok Voice 定价', description: '分组级 Voice realtime / TTS / STT 单价(USD)。留空表示未配置。', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 24498cf7ca..19ef541bc6 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -558,6 +558,7 @@ export interface Group { daily_limit_usd: number | null weekly_limit_usd: number | null monthly_limit_usd: number | null + long_context_pricing_enabled: boolean // 图片生成计费配置 allow_image_generation: boolean allow_batch_image_generation: boolean @@ -604,6 +605,7 @@ export interface Group { } export interface AdminGroup extends Group { + model_pricing: import('@/api/admin/channels').ChannelModelPricing[] // 分组利润控制(openai/anthropic/gemini/grok/antigravity 分组可启用;margin/buffer 为小数存储)。 // 仅管理员可见:与 rate_multiplier 相乘即可反推上游成本上限,不得下放到 Group。 profit_control_enabled: boolean @@ -766,6 +768,8 @@ export interface CreateGroupRequest { daily_limit_usd?: number | null weekly_limit_usd?: number | null monthly_limit_usd?: number | null + long_context_pricing_enabled?: boolean + model_pricing?: import('@/api/admin/channels').ChannelModelPricing[] allow_image_generation?: boolean allow_batch_image_generation?: boolean image_rate_independent?: boolean @@ -826,6 +830,8 @@ export interface UpdateGroupRequest { daily_limit_usd?: number | null weekly_limit_usd?: number | null monthly_limit_usd?: number | null + long_context_pricing_enabled?: boolean + model_pricing?: import('@/api/admin/channels').ChannelModelPricing[] allow_image_generation?: boolean allow_batch_image_generation?: boolean image_rate_independent?: boolean diff --git a/frontend/src/views/admin/GroupsView.vue b/frontend/src/views/admin/GroupsView.vue index bc9ac2d19b..4815749e83 100644 --- a/frontend/src/views/admin/GroupsView.vue +++ b/frontend/src/views/admin/GroupsView.vue @@ -1484,6 +1484,25 @@
+
+
+
+

{{ t("admin.groups.modelPricing.title") }}

+

{{ t("admin.groups.modelPricing.description") }}

+
+ +
+ +
+ +
+
+
+
+
+
+

{{ t("admin.groups.modelPricing.title") }}

+

{{ t("admin.groups.modelPricing.description") }}

+
+ +
+ +
+ +
+
+
({ + models: [], + billing_mode: "token", + input_price: null, + output_price: null, + cache_write_price: null, + cache_read_price: null, + image_input_price: null, + image_output_price: null, + per_request_price: null, + intervals: [], +}); + +const addGroupPricing = (entries: PricingFormEntry[]) => + entries.push(emptyGroupPricing()); + +const groupPricingFromAPI = ( + pricing: ChannelModelPricing[] | undefined, +): PricingFormEntry[] => + (pricing || []).map((entry) => ({ + models: entry.models || [], + billing_mode: entry.billing_mode || "token", + input_price: perTokenToMTok(entry.input_price), + output_price: perTokenToMTok(entry.output_price), + cache_write_price: perTokenToMTok(entry.cache_write_price), + cache_read_price: perTokenToMTok(entry.cache_read_price), + image_input_price: perTokenToMTok(entry.image_input_price), + image_output_price: perTokenToMTok(entry.image_output_price), + per_request_price: entry.per_request_price, + intervals: apiIntervalsToForm(entry.intervals || []), + })); + +const groupPricingToAPI = ( + pricing: PricingFormEntry[], + platform: string, +): ChannelModelPricing[] => + pricing + .filter((entry) => entry.models.length > 0) + .map((entry) => ({ + platform, + models: entry.models, + billing_mode: entry.billing_mode, + input_price: mTokToPerToken(entry.input_price), + output_price: mTokToPerToken(entry.output_price), + cache_write_price: mTokToPerToken(entry.cache_write_price), + cache_read_price: mTokToPerToken(entry.cache_read_price), + image_input_price: mTokToPerToken(entry.image_input_price), + image_output_price: mTokToPerToken(entry.image_output_price), + per_request_price: toNullableNumber(entry.per_request_price), + intervals: formIntervalsToAPI(entry.intervals || []), + })); + const { t } = useI18n(); const appStore = useAppStore(); const onboardingStore = useOnboardingStore(); @@ -4913,6 +5013,8 @@ const createForm = reactive({ daily_limit_usd: null as number | null, weekly_limit_usd: null as number | null, monthly_limit_usd: null as number | null, + long_context_pricing_enabled: false, + model_pricing: [] as PricingFormEntry[], // 图片生成计费配置 allow_image_generation: false, allow_batch_image_generation: false, @@ -5272,6 +5374,8 @@ const editForm = reactive({ daily_limit_usd: null as number | null, weekly_limit_usd: null as number | null, monthly_limit_usd: null as number | null, + long_context_pricing_enabled: false, + model_pricing: [] as PricingFormEntry[], // 图片生成计费配置 allow_image_generation: false, allow_batch_image_generation: false, @@ -5744,6 +5848,8 @@ const closeCreateModal = () => { createForm.video_price_720p = null; createForm.video_price_1080p = null; createForm.video_model_prices = createVideoModelPricesForm(); + createForm.long_context_pricing_enabled = false; + createForm.model_pricing = []; createForm.web_search_price_per_call = null; createForm.search_price_per_1k = null; createForm.audio_realtime_price_per_min = null; @@ -5843,6 +5949,10 @@ const handleCreateGroup = async () => { // 构建请求数据,包含模型路由配置 const requestData = { ...createGroupForm, + model_pricing: groupPricingToAPI( + createForm.model_pricing, + createForm.platform, + ), daily_limit_usd: normalizeOptionalLimit( createForm.daily_limit_usd as number | string | null, ), @@ -5965,6 +6075,9 @@ const handleEdit = async (group: AdminGroup) => { editForm.daily_limit_usd = group.daily_limit_usd; editForm.weekly_limit_usd = group.weekly_limit_usd; editForm.monthly_limit_usd = group.monthly_limit_usd; + editForm.long_context_pricing_enabled = + group.long_context_pricing_enabled ?? false; + editForm.model_pricing = groupPricingFromAPI(group.model_pricing); editForm.allow_image_generation = group.allow_image_generation ?? false; editForm.allow_batch_image_generation = group.allow_batch_image_generation ?? false; @@ -6069,6 +6182,8 @@ const closeEditModal = () => { editForm.video_price_720p = null; editForm.video_price_1080p = null; editForm.video_model_prices = createVideoModelPricesForm(); + editForm.long_context_pricing_enabled = false; + editForm.model_pricing = []; editForm.web_search_price_per_call = null; editForm.search_price_per_1k = null; editForm.audio_realtime_price_per_min = null; @@ -6101,6 +6216,10 @@ const handleUpdateGroup = async () => { // 转换 fallback_group_id: null -> 0 (后端使用 0 表示清除) const payload = { ...editForm, + model_pricing: groupPricingToAPI( + editForm.model_pricing, + editForm.platform, + ), daily_limit_usd: normalizeOptionalLimit( editForm.daily_limit_usd as number | string | null, ),