feat: 分组支持逐模型定价,并可关闭长上下文阶梯

运营需要按分组覆盖渠道/内置价,且部分套餐不应自动吃 200k 倍率。
原先只能改渠道价卡,分组侧只剩 Voice 三列。

- groups 新增 model_pricing / long_context_pricing_enabled,解析链改为 Group → Channel → 内置
- 关闭长上下文时 token 模型只取最低档;video 按秒计费可写进同一价卡
- 回退价对齐官方卡:4.5 缓存 $0.30、4.3/imagine/audio/search 默认值一并校正
This commit is contained in:
IanShaw027
2026-08-13 07:49:08 +08:00
parent 69648476d4
commit f3d9491071
35 changed files with 1094 additions and 176 deletions
+26 -2
View File
@@ -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(", ")
+13
View File
@@ -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()
+25
View File
@@ -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))
+136
View File
@@ -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) {
+93
View File
@@ -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)
}
+3 -1
View File
@@ -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",
+145 -1
View File
@@ -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
+22 -18
View File
@@ -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()
+9
View File
@@ -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").
+27 -19
View File
@@ -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,
+2
View File
@@ -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,
+9 -7
View File
@@ -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"`
@@ -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,
+13
View File
@@ -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).
+38
View File
@@ -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 {
+23 -19
View File
@@ -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
@@ -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)
+69 -39
View File
@@ -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,
@@ -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)
})
}
+30 -30
View File
@@ -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)
+1 -1
View File
@@ -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",
@@ -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)
}
+5
View File
@@ -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
@@ -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)
}
}
@@ -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)
}
@@ -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
@@ -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';
@@ -194,11 +194,11 @@
</div>
</div>
<!-- Image mode -->
<div v-else-if="entry.billing_mode === 'image'">
<!-- Image/video mode -->
<div v-else-if="entry.billing_mode === 'image' || entry.billing_mode === 'video'">
<!-- Default image price (per-request, same as per_request mode) -->
<label class="mt-3 block text-xs font-medium text-gray-500 dark:text-gray-400">
{{ t('admin.channels.form.defaultImagePrice') }}
{{ entry.billing_mode === 'video' ? t('admin.channels.form.defaultVideoPrice') : t('admin.channels.form.defaultImagePrice') }}
<span class="ml-1 font-normal text-gray-400">$</span>
</label>
<div class="mt-1 w-48">
@@ -209,9 +209,9 @@
<!-- Image tiers -->
<div class="mt-3 flex items-center justify-between">
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">
{{ t('admin.channels.form.imageTiers') }}
{{ entry.billing_mode === 'video' ? t('admin.channels.form.videoTiers') : t('admin.channels.form.imageTiers') }}
</label>
<button type="button" @click="addImageTier" class="text-xs text-primary-600 hover:text-primary-700">
<button type="button" @click="addMediaTier" class="text-xs text-primary-600 hover:text-primary-700">
+ {{ t('admin.channels.form.addTier') }}
</button>
</div>
@@ -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,
+2
View File
@@ -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
@@ -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.',
@@ -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.',
@@ -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 请求,请谨慎操作',
@@ -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)。留空表示未配置。',
+6
View File
@@ -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
+119
View File
@@ -1484,6 +1484,25 @@
</div>
<div class="border-t border-gray-200 pt-4 mt-4 dark:border-dark-400">
<div class="flex items-start justify-between gap-4">
<div>
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t("admin.groups.modelPricing.title") }}</h4>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t("admin.groups.modelPricing.description") }}</p>
</div>
<button type="button" class="btn btn-secondary" @click="addGroupPricing(createForm.model_pricing)">
<Icon name="plus" size="sm" class="mr-1" />{{ t("admin.groups.modelPricing.add") }}
</button>
</div>
<label class="mt-3 flex items-start gap-2">
<input v-model="createForm.long_context_pricing_enabled" type="checkbox" class="mt-0.5" />
<span><span class="block text-sm text-gray-700 dark:text-gray-300">{{ t("admin.groups.modelPricing.longContext") }}</span><span class="block text-xs text-gray-500">{{ t("admin.groups.modelPricing.longContextHint") }}</span></span>
</label>
<div class="mt-3 space-y-2">
<PricingEntryCard v-for="(entry, index) in createForm.model_pricing" :key="index" :entry="entry" :platform="createForm.platform" @update="createForm.model_pricing[index] = $event" @remove="createForm.model_pricing.splice(index, 1)" />
</div>
</div>
<!-- Grok Voice 显式定价(仅 grok 平台) -->
<div
v-if="createForm.platform === 'grok'"
@@ -3187,6 +3206,25 @@
</div>
<div class="border-t border-gray-200 pt-4 mt-4 dark:border-dark-400">
<div class="flex items-start justify-between gap-4">
<div>
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t("admin.groups.modelPricing.title") }}</h4>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t("admin.groups.modelPricing.description") }}</p>
</div>
<button type="button" class="btn btn-secondary" @click="addGroupPricing(editForm.model_pricing)">
<Icon name="plus" size="sm" class="mr-1" />{{ t("admin.groups.modelPricing.add") }}
</button>
</div>
<label class="mt-3 flex items-start gap-2">
<input v-model="editForm.long_context_pricing_enabled" type="checkbox" class="mt-0.5" />
<span><span class="block text-sm text-gray-700 dark:text-gray-300">{{ t("admin.groups.modelPricing.longContext") }}</span><span class="block text-xs text-gray-500">{{ t("admin.groups.modelPricing.longContextHint") }}</span></span>
</label>
<div class="mt-3 space-y-2">
<PricingEntryCard v-for="(entry, index) in editForm.model_pricing" :key="index" :entry="entry" :platform="editForm.platform" @update="editForm.model_pricing[index] = $event" @remove="editForm.model_pricing.splice(index, 1)" />
</div>
</div>
<!-- Grok Voice 显式定价(仅 grok 平台) -->
<div
v-if="editForm.platform === 'grok'"
@@ -4377,6 +4415,16 @@ import GroupRateMultipliersModal from "@/components/admin/group/GroupRateMultipl
import GroupRPMOverridesModal from "@/components/admin/group/GroupRPMOverridesModal.vue";
import GroupCapacityBadge from "@/components/common/GroupCapacityBadge.vue";
import ReasoningEffortPolicyFields from "@/components/admin/group/ReasoningEffortPolicyFields.vue";
import PricingEntryCard from "@/components/admin/channel/PricingEntryCard.vue";
import type { PricingFormEntry } from "@/components/admin/channel/types";
import {
apiIntervalsToForm,
formIntervalsToAPI,
mTokToPerToken,
perTokenToMTok,
toNullableNumber,
} from "@/components/admin/channel/types";
import type { ChannelModelPricing } from "@/api/admin/channels";
import { VueDraggable } from "vue-draggable-plus";
import { createStableObjectKeyResolver } from "@/utils/stableObjectKey";
import { extractApiErrorMessage } from "@/utils/apiError";
@@ -4430,6 +4478,58 @@ import {
videoModelPriceFamilyRows,
} from "./groupsVideoModelPricing";
const emptyGroupPricing = (): PricingFormEntry => ({
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,
),