From eebf4e3ffbfbb7715e31eb141a8360f26c296edb Mon Sep 17 00:00:00 2001 From: Ritel-T Date: Tue, 1 Sep 2026 04:30:57 +0900 Subject: [PATCH 01/13] Add the free Fast migration --- .../migrations_schema_integration_test.go | 1 + .../migrations/233_group_free_openai_fast.sql | 5 +++++ .../group_free_openai_fast_migration_test.go | 18 ++++++++++++++++++ 3 files changed, 24 insertions(+) create mode 100644 backend/migrations/233_group_free_openai_fast.sql create mode 100644 backend/migrations/group_free_openai_fast_migration_test.go diff --git a/backend/internal/repository/migrations_schema_integration_test.go b/backend/internal/repository/migrations_schema_integration_test.go index 4d32cce370..57f06fd2a8 100644 --- a/backend/internal/repository/migrations_schema_integration_test.go +++ b/backend/internal/repository/migrations_schema_integration_test.go @@ -58,6 +58,7 @@ func TestMigrationsRunner_IsIdempotent_AndSchemaIsUpToDate(t *testing.T) { // groups: OpenAI Live 与 Fast 强制策略都默认关闭,管理员显式开启后才生效。 requireColumn(t, tx, "groups", "allow_live", "boolean", 0, false) requireColumn(t, tx, "groups", "force_openai_fast", "boolean", 0, false) + requireColumn(t, tx, "groups", "free_openai_fast", "boolean", 0, false) // api_keys: key length should be 128 requireColumn(t, tx, "api_keys", "key", "character varying", 128, false) diff --git a/backend/migrations/233_group_free_openai_fast.sql b/backend/migrations/233_group_free_openai_fast.sql new file mode 100644 index 0000000000..ef84cd31ca --- /dev/null +++ b/backend/migrations/233_group_free_openai_fast.sql @@ -0,0 +1,5 @@ +ALTER TABLE groups + ADD COLUMN IF NOT EXISTS free_openai_fast BOOLEAN NOT NULL DEFAULT FALSE; + +COMMENT ON COLUMN groups.free_openai_fast IS + 'Whether Fast/priority requests in this OpenAI/Composite group are billed to users at Standard price'; diff --git a/backend/migrations/group_free_openai_fast_migration_test.go b/backend/migrations/group_free_openai_fast_migration_test.go new file mode 100644 index 0000000000..0943e0306b --- /dev/null +++ b/backend/migrations/group_free_openai_fast_migration_test.go @@ -0,0 +1,18 @@ +package migrations + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGroupFreeOpenAIFastMigration(t *testing.T) { + content, err := FS.ReadFile("233_group_free_openai_fast.sql") + require.NoError(t, err) + + sql := strings.Join(strings.Fields(string(content)), " ") + require.Contains(t, sql, + "ADD COLUMN IF NOT EXISTS free_openai_fast BOOLEAN NOT NULL DEFAULT FALSE") + require.Contains(t, sql, "COMMENT ON COLUMN groups.free_openai_fast") +} From e7caedc22ab05bf7bb9a637c26d8c14464bd0bfd Mon Sep 17 00:00:00 2001 From: Ritel-T Date: Tue, 1 Sep 2026 04:30:59 +0900 Subject: [PATCH 02/13] Regenerate Group persistence for free Fast --- backend/ent/group.go | 13 ++++++- backend/ent/group/group.go | 10 ++++++ backend/ent/group/where.go | 15 ++++++++ backend/ent/group_create.go | 65 ++++++++++++++++++++++++++++++++++ backend/ent/group_update.go | 34 ++++++++++++++++++ backend/ent/migrate/schema.go | 1 + backend/ent/mutation.go | 56 ++++++++++++++++++++++++++++- backend/ent/runtime/runtime.go | 26 ++++++++------ backend/ent/schema/group.go | 3 ++ 9 files changed, 210 insertions(+), 13 deletions(-) diff --git a/backend/ent/group.go b/backend/ent/group.go index 29912a2133..61f5caa349 100644 --- a/backend/ent/group.go +++ b/backend/ent/group.go @@ -124,6 +124,8 @@ type Group struct { AllowLive bool `json:"allow_live,omitempty"` // 是否强制此 OpenAI/Composite 分组请求使用 service_tier=priority ForceOpenaiFast bool `json:"force_openai_fast,omitempty"` + // 是否让此 OpenAI/Composite 分组的 Fast 请求按 Standard 价格计费 + FreeOpenaiFast bool `json:"free_openai_fast,omitempty"` // 仅允许非 apikey 类型账号关联到此分组 RequireOauthOnly bool `json:"require_oauth_only,omitempty"` // 调度时仅允许 privacy 已成功设置的账号 @@ -254,7 +256,7 @@ func (*Group) scanValues(columns []string) ([]any, error) { switch columns[i] { 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.FieldLongContextPricingEnabled, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch, group.FieldAllowLive, group.FieldForceOpenaiFast, 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.FieldForceOpenaiFast, group.FieldFreeOpenaiFast, 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) @@ -624,6 +626,12 @@ func (_m *Group) assignValues(columns []string, values []any) error { } else if value.Valid { _m.ForceOpenaiFast = value.Bool } + case group.FieldFreeOpenaiFast: + if value, ok := values[i].(*sql.NullBool); !ok { + return fmt.Errorf("unexpected type %T for field free_openai_fast", values[i]) + } else if value.Valid { + _m.FreeOpenaiFast = value.Bool + } case group.FieldRequireOauthOnly: if value, ok := values[i].(*sql.NullBool); !ok { return fmt.Errorf("unexpected type %T for field require_oauth_only", values[i]) @@ -966,6 +974,9 @@ func (_m *Group) String() string { builder.WriteString("force_openai_fast=") builder.WriteString(fmt.Sprintf("%v", _m.ForceOpenaiFast)) builder.WriteString(", ") + builder.WriteString("free_openai_fast=") + builder.WriteString(fmt.Sprintf("%v", _m.FreeOpenaiFast)) + builder.WriteString(", ") builder.WriteString("require_oauth_only=") builder.WriteString(fmt.Sprintf("%v", _m.RequireOauthOnly)) builder.WriteString(", ") diff --git a/backend/ent/group/group.go b/backend/ent/group/group.go index 87cdad3dff..b795a888c1 100644 --- a/backend/ent/group/group.go +++ b/backend/ent/group/group.go @@ -120,6 +120,8 @@ const ( FieldAllowLive = "allow_live" // FieldForceOpenaiFast holds the string denoting the force_openai_fast field in the database. FieldForceOpenaiFast = "force_openai_fast" + // FieldFreeOpenaiFast holds the string denoting the free_openai_fast field in the database. + FieldFreeOpenaiFast = "free_openai_fast" // FieldRequireOauthOnly holds the string denoting the require_oauth_only field in the database. FieldRequireOauthOnly = "require_oauth_only" // FieldRequirePrivacySet holds the string denoting the require_privacy_set field in the database. @@ -269,6 +271,7 @@ var Columns = []string{ FieldAllowMessagesDispatch, FieldAllowLive, FieldForceOpenaiFast, + FieldFreeOpenaiFast, FieldRequireOauthOnly, FieldRequirePrivacySet, FieldDefaultMappedModel, @@ -391,6 +394,8 @@ var ( DefaultAllowLive bool // DefaultForceOpenaiFast holds the default value on creation for the "force_openai_fast" field. DefaultForceOpenaiFast bool + // DefaultFreeOpenaiFast holds the default value on creation for the "free_openai_fast" field. + DefaultFreeOpenaiFast bool // DefaultRequireOauthOnly holds the default value on creation for the "require_oauth_only" field. DefaultRequireOauthOnly bool // DefaultRequirePrivacySet holds the default value on creation for the "require_privacy_set" field. @@ -667,6 +672,11 @@ func ByForceOpenaiFast(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldForceOpenaiFast, opts...).ToFunc() } +// ByFreeOpenaiFast orders the results by the free_openai_fast field. +func ByFreeOpenaiFast(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldFreeOpenaiFast, opts...).ToFunc() +} + // ByRequireOauthOnly orders the results by the require_oauth_only field. func ByRequireOauthOnly(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldRequireOauthOnly, opts...).ToFunc() diff --git a/backend/ent/group/where.go b/backend/ent/group/where.go index 357c6921b8..aaebb4a363 100644 --- a/backend/ent/group/where.go +++ b/backend/ent/group/where.go @@ -295,6 +295,11 @@ func ForceOpenaiFast(v bool) predicate.Group { return predicate.Group(sql.FieldEQ(FieldForceOpenaiFast, v)) } +// FreeOpenaiFast applies equality check predicate on the "free_openai_fast" field. It's identical to FreeOpenaiFastEQ. +func FreeOpenaiFast(v bool) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldFreeOpenaiFast, v)) +} + // RequireOauthOnly applies equality check predicate on the "require_oauth_only" field. It's identical to RequireOauthOnlyEQ. func RequireOauthOnly(v bool) predicate.Group { return predicate.Group(sql.FieldEQ(FieldRequireOauthOnly, v)) @@ -2285,6 +2290,16 @@ func ForceOpenaiFastNEQ(v bool) predicate.Group { return predicate.Group(sql.FieldNEQ(FieldForceOpenaiFast, v)) } +// FreeOpenaiFastEQ applies the EQ predicate on the "free_openai_fast" field. +func FreeOpenaiFastEQ(v bool) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldFreeOpenaiFast, v)) +} + +// FreeOpenaiFastNEQ applies the NEQ predicate on the "free_openai_fast" field. +func FreeOpenaiFastNEQ(v bool) predicate.Group { + return predicate.Group(sql.FieldNEQ(FieldFreeOpenaiFast, v)) +} + // RequireOauthOnlyEQ applies the EQ predicate on the "require_oauth_only" field. func RequireOauthOnlyEQ(v bool) predicate.Group { return predicate.Group(sql.FieldEQ(FieldRequireOauthOnly, v)) diff --git a/backend/ent/group_create.go b/backend/ent/group_create.go index 9e0a846ee5..2089224707 100644 --- a/backend/ent/group_create.go +++ b/backend/ent/group_create.go @@ -718,6 +718,20 @@ func (_c *GroupCreate) SetNillableForceOpenaiFast(v *bool) *GroupCreate { return _c } +// SetFreeOpenaiFast sets the "free_openai_fast" field. +func (_c *GroupCreate) SetFreeOpenaiFast(v bool) *GroupCreate { + _c.mutation.SetFreeOpenaiFast(v) + return _c +} + +// SetNillableFreeOpenaiFast sets the "free_openai_fast" field if the given value is not nil. +func (_c *GroupCreate) SetNillableFreeOpenaiFast(v *bool) *GroupCreate { + if v != nil { + _c.SetFreeOpenaiFast(*v) + } + return _c +} + // SetRequireOauthOnly sets the "require_oauth_only" field. func (_c *GroupCreate) SetRequireOauthOnly(v bool) *GroupCreate { _c.mutation.SetRequireOauthOnly(v) @@ -1113,6 +1127,10 @@ func (_c *GroupCreate) defaults() error { v := group.DefaultForceOpenaiFast _c.mutation.SetForceOpenaiFast(v) } + if _, ok := _c.mutation.FreeOpenaiFast(); !ok { + v := group.DefaultFreeOpenaiFast + _c.mutation.SetFreeOpenaiFast(v) + } if _, ok := _c.mutation.RequireOauthOnly(); !ok { v := group.DefaultRequireOauthOnly _c.mutation.SetRequireOauthOnly(v) @@ -1307,6 +1325,9 @@ func (_c *GroupCreate) check() error { if _, ok := _c.mutation.ForceOpenaiFast(); !ok { return &ValidationError{Name: "force_openai_fast", err: errors.New(`ent: missing required field "Group.force_openai_fast"`)} } + if _, ok := _c.mutation.FreeOpenaiFast(); !ok { + return &ValidationError{Name: "free_openai_fast", err: errors.New(`ent: missing required field "Group.free_openai_fast"`)} + } if _, ok := _c.mutation.RequireOauthOnly(); !ok { return &ValidationError{Name: "require_oauth_only", err: errors.New(`ent: missing required field "Group.require_oauth_only"`)} } @@ -1585,6 +1606,10 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) { _spec.SetField(group.FieldForceOpenaiFast, field.TypeBool, value) _node.ForceOpenaiFast = value } + if value, ok := _c.mutation.FreeOpenaiFast(); ok { + _spec.SetField(group.FieldFreeOpenaiFast, field.TypeBool, value) + _node.FreeOpenaiFast = value + } if value, ok := _c.mutation.RequireOauthOnly(); ok { _spec.SetField(group.FieldRequireOauthOnly, field.TypeBool, value) _node.RequireOauthOnly = value @@ -2655,6 +2680,18 @@ func (u *GroupUpsert) UpdateForceOpenaiFast() *GroupUpsert { return u } +// SetFreeOpenaiFast sets the "free_openai_fast" field. +func (u *GroupUpsert) SetFreeOpenaiFast(v bool) *GroupUpsert { + u.Set(group.FieldFreeOpenaiFast, v) + return u +} + +// UpdateFreeOpenaiFast sets the "free_openai_fast" field to the value that was provided on create. +func (u *GroupUpsert) UpdateFreeOpenaiFast() *GroupUpsert { + u.SetExcluded(group.FieldFreeOpenaiFast) + return u +} + // SetRequireOauthOnly sets the "require_oauth_only" field. func (u *GroupUpsert) SetRequireOauthOnly(v bool) *GroupUpsert { u.Set(group.FieldRequireOauthOnly, v) @@ -3868,6 +3905,20 @@ func (u *GroupUpsertOne) UpdateForceOpenaiFast() *GroupUpsertOne { }) } +// SetFreeOpenaiFast sets the "free_openai_fast" field. +func (u *GroupUpsertOne) SetFreeOpenaiFast(v bool) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.SetFreeOpenaiFast(v) + }) +} + +// UpdateFreeOpenaiFast sets the "free_openai_fast" field to the value that was provided on create. +func (u *GroupUpsertOne) UpdateFreeOpenaiFast() *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.UpdateFreeOpenaiFast() + }) +} + // SetRequireOauthOnly sets the "require_oauth_only" field. func (u *GroupUpsertOne) SetRequireOauthOnly(v bool) *GroupUpsertOne { return u.Update(func(s *GroupUpsert) { @@ -5272,6 +5323,20 @@ func (u *GroupUpsertBulk) UpdateForceOpenaiFast() *GroupUpsertBulk { }) } +// SetFreeOpenaiFast sets the "free_openai_fast" field. +func (u *GroupUpsertBulk) SetFreeOpenaiFast(v bool) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.SetFreeOpenaiFast(v) + }) +} + +// UpdateFreeOpenaiFast sets the "free_openai_fast" field to the value that was provided on create. +func (u *GroupUpsertBulk) UpdateFreeOpenaiFast() *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.UpdateFreeOpenaiFast() + }) +} + // SetRequireOauthOnly sets the "require_oauth_only" field. func (u *GroupUpsertBulk) SetRequireOauthOnly(v bool) *GroupUpsertBulk { return u.Update(func(s *GroupUpsert) { diff --git a/backend/ent/group_update.go b/backend/ent/group_update.go index ed68bcacb9..44f26608d5 100644 --- a/backend/ent/group_update.go +++ b/backend/ent/group_update.go @@ -1003,6 +1003,20 @@ func (_u *GroupUpdate) SetNillableForceOpenaiFast(v *bool) *GroupUpdate { return _u } +// SetFreeOpenaiFast sets the "free_openai_fast" field. +func (_u *GroupUpdate) SetFreeOpenaiFast(v bool) *GroupUpdate { + _u.mutation.SetFreeOpenaiFast(v) + return _u +} + +// SetNillableFreeOpenaiFast sets the "free_openai_fast" field if the given value is not nil. +func (_u *GroupUpdate) SetNillableFreeOpenaiFast(v *bool) *GroupUpdate { + if v != nil { + _u.SetFreeOpenaiFast(*v) + } + return _u +} + // SetRequireOauthOnly sets the "require_oauth_only" field. func (_u *GroupUpdate) SetRequireOauthOnly(v bool) *GroupUpdate { _u.mutation.SetRequireOauthOnly(v) @@ -1814,6 +1828,9 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) { if value, ok := _u.mutation.ForceOpenaiFast(); ok { _spec.SetField(group.FieldForceOpenaiFast, field.TypeBool, value) } + if value, ok := _u.mutation.FreeOpenaiFast(); ok { + _spec.SetField(group.FieldFreeOpenaiFast, field.TypeBool, value) + } if value, ok := _u.mutation.RequireOauthOnly(); ok { _spec.SetField(group.FieldRequireOauthOnly, field.TypeBool, value) } @@ -3141,6 +3158,20 @@ func (_u *GroupUpdateOne) SetNillableForceOpenaiFast(v *bool) *GroupUpdateOne { return _u } +// SetFreeOpenaiFast sets the "free_openai_fast" field. +func (_u *GroupUpdateOne) SetFreeOpenaiFast(v bool) *GroupUpdateOne { + _u.mutation.SetFreeOpenaiFast(v) + return _u +} + +// SetNillableFreeOpenaiFast sets the "free_openai_fast" field if the given value is not nil. +func (_u *GroupUpdateOne) SetNillableFreeOpenaiFast(v *bool) *GroupUpdateOne { + if v != nil { + _u.SetFreeOpenaiFast(*v) + } + return _u +} + // SetRequireOauthOnly sets the "require_oauth_only" field. func (_u *GroupUpdateOne) SetRequireOauthOnly(v bool) *GroupUpdateOne { _u.mutation.SetRequireOauthOnly(v) @@ -3982,6 +4013,9 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error) if value, ok := _u.mutation.ForceOpenaiFast(); ok { _spec.SetField(group.FieldForceOpenaiFast, field.TypeBool, value) } + if value, ok := _u.mutation.FreeOpenaiFast(); ok { + _spec.SetField(group.FieldFreeOpenaiFast, field.TypeBool, value) + } if value, ok := _u.mutation.RequireOauthOnly(); ok { _spec.SetField(group.FieldRequireOauthOnly, field.TypeBool, value) } diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index 8aa1cc7a69..180de294f3 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -955,6 +955,7 @@ var ( {Name: "allow_messages_dispatch", Type: field.TypeBool, Default: false}, {Name: "allow_live", Type: field.TypeBool, Default: false}, {Name: "force_openai_fast", Type: field.TypeBool, Default: false}, + {Name: "free_openai_fast", Type: field.TypeBool, Default: false}, {Name: "require_oauth_only", Type: field.TypeBool, Default: false}, {Name: "require_privacy_set", Type: field.TypeBool, Default: false}, {Name: "default_mapped_model", Type: field.TypeString, Size: 100, Default: ""}, diff --git a/backend/ent/mutation.go b/backend/ent/mutation.go index 6c22f3373c..dc724cb011 100644 --- a/backend/ent/mutation.go +++ b/backend/ent/mutation.go @@ -22159,6 +22159,7 @@ type GroupMutation struct { allow_messages_dispatch *bool allow_live *bool force_openai_fast *bool + free_openai_fast *bool require_oauth_only *bool require_privacy_set *bool default_mapped_model *string @@ -24981,6 +24982,42 @@ func (m *GroupMutation) ResetForceOpenaiFast() { m.force_openai_fast = nil } +// SetFreeOpenaiFast sets the "free_openai_fast" field. +func (m *GroupMutation) SetFreeOpenaiFast(b bool) { + m.free_openai_fast = &b +} + +// FreeOpenaiFast returns the value of the "free_openai_fast" field in the mutation. +func (m *GroupMutation) FreeOpenaiFast() (r bool, exists bool) { + v := m.free_openai_fast + if v == nil { + return + } + return *v, true +} + +// OldFreeOpenaiFast returns the old "free_openai_fast" 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) OldFreeOpenaiFast(ctx context.Context) (v bool, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldFreeOpenaiFast is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldFreeOpenaiFast requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldFreeOpenaiFast: %w", err) + } + return oldValue.FreeOpenaiFast, nil +} + +// ResetFreeOpenaiFast resets all changes to the "free_openai_fast" field. +func (m *GroupMutation) ResetFreeOpenaiFast() { + m.free_openai_fast = nil +} + // SetRequireOauthOnly sets the "require_oauth_only" field. func (m *GroupMutation) SetRequireOauthOnly(b bool) { m.require_oauth_only = &b @@ -25810,7 +25847,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, 63) + fields := make([]string, 0, 64) if m.created_at != nil { fields = append(fields, group.FieldCreatedAt) } @@ -25967,6 +26004,9 @@ func (m *GroupMutation) Fields() []string { if m.force_openai_fast != nil { fields = append(fields, group.FieldForceOpenaiFast) } + if m.free_openai_fast != nil { + fields = append(fields, group.FieldFreeOpenaiFast) + } if m.require_oauth_only != nil { fields = append(fields, group.FieldRequireOauthOnly) } @@ -26112,6 +26152,8 @@ func (m *GroupMutation) Field(name string) (ent.Value, bool) { return m.AllowLive() case group.FieldForceOpenaiFast: return m.ForceOpenaiFast() + case group.FieldFreeOpenaiFast: + return m.FreeOpenaiFast() case group.FieldRequireOauthOnly: return m.RequireOauthOnly() case group.FieldRequirePrivacySet: @@ -26247,6 +26289,8 @@ func (m *GroupMutation) OldField(ctx context.Context, name string) (ent.Value, e return m.OldAllowLive(ctx) case group.FieldForceOpenaiFast: return m.OldForceOpenaiFast(ctx) + case group.FieldFreeOpenaiFast: + return m.OldFreeOpenaiFast(ctx) case group.FieldRequireOauthOnly: return m.OldRequireOauthOnly(ctx) case group.FieldRequirePrivacySet: @@ -26642,6 +26686,13 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error { } m.SetForceOpenaiFast(v) return nil + case group.FieldFreeOpenaiFast: + v, ok := value.(bool) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetFreeOpenaiFast(v) + return nil case group.FieldRequireOauthOnly: v, ok := value.(bool) if !ok { @@ -27386,6 +27437,9 @@ func (m *GroupMutation) ResetField(name string) error { case group.FieldForceOpenaiFast: m.ResetForceOpenaiFast() return nil + case group.FieldFreeOpenaiFast: + m.ResetFreeOpenaiFast() + return nil case group.FieldRequireOauthOnly: m.ResetRequireOauthOnly() return nil diff --git a/backend/ent/runtime/runtime.go b/backend/ent/runtime/runtime.go index 8323961ebd..12e214a251 100644 --- a/backend/ent/runtime/runtime.go +++ b/backend/ent/runtime/runtime.go @@ -1161,52 +1161,56 @@ func init() { groupDescForceOpenaiFast := groupFields[48].Descriptor() // group.DefaultForceOpenaiFast holds the default value on creation for the force_openai_fast field. group.DefaultForceOpenaiFast = groupDescForceOpenaiFast.Default.(bool) + // groupDescFreeOpenaiFast is the schema descriptor for free_openai_fast field. + groupDescFreeOpenaiFast := groupFields[49].Descriptor() + // group.DefaultFreeOpenaiFast holds the default value on creation for the free_openai_fast field. + group.DefaultFreeOpenaiFast = groupDescFreeOpenaiFast.Default.(bool) // groupDescRequireOauthOnly is the schema descriptor for require_oauth_only field. - groupDescRequireOauthOnly := groupFields[49].Descriptor() + groupDescRequireOauthOnly := groupFields[50].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[50].Descriptor() + groupDescRequirePrivacySet := groupFields[51].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[51].Descriptor() + groupDescDefaultMappedModel := groupFields[52].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[52].Descriptor() + groupDescMessagesDispatchModelConfig := groupFields[53].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[53].Descriptor() + groupDescModelsListConfig := groupFields[54].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[54].Descriptor() + groupDescRpmLimit := groupFields[55].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[55].Descriptor() + groupDescMaxReasoningEffort := groupFields[56].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[56].Descriptor() + groupDescReasoningEffortMappings := groupFields[57].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[57].Descriptor() + groupDescProfitControlEnabled := groupFields[58].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[58].Descriptor() + groupDescProfitMinMargin := groupFields[59].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[59].Descriptor() + groupDescProfitSafetyBuffer := groupFields[60].Descriptor() // group.DefaultProfitSafetyBuffer holds the default value on creation for the profit_safety_buffer field. group.DefaultProfitSafetyBuffer = groupDescProfitSafetyBuffer.Default.(float64) idempotencyrecordMixin := schema.IdempotencyRecord{}.Mixin() diff --git a/backend/ent/schema/group.go b/backend/ent/schema/group.go index 01efd0f5c3..a356447c3a 100644 --- a/backend/ent/schema/group.go +++ b/backend/ent/schema/group.go @@ -245,6 +245,9 @@ func (Group) Fields() []ent.Field { field.Bool("force_openai_fast"). Default(false). Comment("是否强制此 OpenAI/Composite 分组请求使用 service_tier=priority"), + field.Bool("free_openai_fast"). + Default(false). + Comment("是否让此 OpenAI/Composite 分组的 Fast 请求按 Standard 价格计费"), field.Bool("require_oauth_only"). Default(false). Comment("仅允许非 apikey 类型账号关联到此分组"), From fb409787c68a38fe3e013c6c7ec431353fd127f4 Mon Sep 17 00:00:00 2001 From: Ritel-T Date: Tue, 1 Sep 2026 04:31:00 +0900 Subject: [PATCH 03/13] Persist the free Fast group policy --- backend/internal/repository/group_repo.go | 2 ++ backend/internal/service/group.go | 1 + 2 files changed, 3 insertions(+) diff --git a/backend/internal/repository/group_repo.go b/backend/internal/repository/group_repo.go index 125456cd59..cc65e88e42 100644 --- a/backend/internal/repository/group_repo.go +++ b/backend/internal/repository/group_repo.go @@ -104,6 +104,7 @@ func createGroupRecord(ctx context.Context, client *dbent.Client, groupIn *servi SetAllowMessagesDispatch(groupIn.AllowMessagesDispatch). SetAllowLive(groupIn.AllowLive). SetForceOpenaiFast(groupIn.ForceOpenAIFast). + SetFreeOpenaiFast(groupIn.FreeOpenAIFast). SetRequireOauthOnly(groupIn.RequireOAuthOnly). SetRequirePrivacySet(groupIn.RequirePrivacySet). SetDefaultMappedModel(groupIn.DefaultMappedModel). @@ -281,6 +282,7 @@ func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) er SetAllowMessagesDispatch(groupIn.AllowMessagesDispatch). SetAllowLive(groupIn.AllowLive). SetForceOpenaiFast(groupIn.ForceOpenAIFast). + SetFreeOpenaiFast(groupIn.FreeOpenAIFast). SetRequireOauthOnly(groupIn.RequireOAuthOnly). SetRequirePrivacySet(groupIn.RequirePrivacySet). SetDefaultMappedModel(groupIn.DefaultMappedModel). diff --git a/backend/internal/service/group.go b/backend/internal/service/group.go index 8703057377..71e2c3bf22 100644 --- a/backend/internal/service/group.go +++ b/backend/internal/service/group.go @@ -101,6 +101,7 @@ type Group struct { AllowMessagesDispatch bool AllowLive bool ForceOpenAIFast bool // 强制 OpenAI 网关请求使用 service_tier=priority + FreeOpenAIFast bool // OpenAI Fast 请求按 Standard 价格向用户计费 RequireOAuthOnly bool // 仅允许非 apikey 类型账号关联(OpenAI/Antigravity/Anthropic/Gemini) RequirePrivacySet bool // 调度时仅允许 privacy 已成功设置的账号(OpenAI/Antigravity/Anthropic/Gemini) DefaultMappedModel string From 7dcf72846183028b7eb32864211f9f492884cc9a Mon Sep 17 00:00:00 2001 From: Ritel-T Date: Tue, 1 Sep 2026 04:31:02 +0900 Subject: [PATCH 04/13] Project free Fast into API key auth --- backend/internal/repository/api_key_repo.go | 2 ++ .../api_key_repo_openai_fast_projection_integration_test.go | 3 ++- backend/internal/repository/fixtures_integration_test.go | 1 + 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/internal/repository/api_key_repo.go b/backend/internal/repository/api_key_repo.go index f1bd5c53d0..14d3aef271 100644 --- a/backend/internal/repository/api_key_repo.go +++ b/backend/internal/repository/api_key_repo.go @@ -211,6 +211,7 @@ func (r *apiKeyRepository) GetByKeyForAuth(ctx context.Context, key string) (*se group.FieldAllowMessagesDispatch, group.FieldAllowLive, group.FieldForceOpenaiFast, + group.FieldFreeOpenaiFast, group.FieldDefaultMappedModel, group.FieldMessagesDispatchModelConfig, group.FieldModelsListConfig, @@ -1009,6 +1010,7 @@ func groupEntityToService(g *dbent.Group) *service.Group { AllowMessagesDispatch: g.AllowMessagesDispatch, AllowLive: g.AllowLive, ForceOpenAIFast: g.ForceOpenaiFast, + FreeOpenAIFast: g.FreeOpenaiFast, RequireOAuthOnly: g.RequireOauthOnly, RequirePrivacySet: g.RequirePrivacySet, DefaultMappedModel: g.DefaultMappedModel, diff --git a/backend/internal/repository/api_key_repo_openai_fast_projection_integration_test.go b/backend/internal/repository/api_key_repo_openai_fast_projection_integration_test.go index a3e00a541d..f3687c3070 100644 --- a/backend/internal/repository/api_key_repo_openai_fast_projection_integration_test.go +++ b/backend/internal/repository/api_key_repo_openai_fast_projection_integration_test.go @@ -17,7 +17,7 @@ func TestGetByKeyForAuthCarriesGroupForceOpenAIFast(t *testing.T) { suffix := time.Now().UnixNano() group := mustCreateGroup(t, integrationEntClient, &service.Group{ Name: fmt.Sprintf("fast-proj-group-%d", suffix), Platform: service.PlatformOpenAI, - RateMultiplier: 1, ForceOpenAIFast: true, + RateMultiplier: 1, ForceOpenAIFast: true, FreeOpenAIFast: true, }) user := mustCreateUser(t, integrationEntClient, &service.User{ Email: fmt.Sprintf("fast-proj-%d@example.com", suffix), Concurrency: 5, @@ -42,4 +42,5 @@ func TestGetByKeyForAuthCarriesGroupForceOpenAIFast(t *testing.T) { require.NoError(t, err) require.NotNil(t, got.Group) require.True(t, got.Group.ForceOpenAIFast) + require.True(t, got.Group.FreeOpenAIFast) } diff --git a/backend/internal/repository/fixtures_integration_test.go b/backend/internal/repository/fixtures_integration_test.go index 987881f143..005ec7d0ba 100644 --- a/backend/internal/repository/fixtures_integration_test.go +++ b/backend/internal/repository/fixtures_integration_test.go @@ -91,6 +91,7 @@ func mustCreateGroup(t *testing.T, client *dbent.Client, g *service.Group) *serv SetRateMultiplier(g.RateMultiplier). SetIsExclusive(g.IsExclusive). SetForceOpenaiFast(g.ForceOpenAIFast). + SetFreeOpenaiFast(g.FreeOpenAIFast). SetProfitControlEnabled(g.ProfitControlEnabled). SetProfitMinMargin(g.ProfitMinMargin). SetProfitSafetyBuffer(g.ProfitSafetyBuffer) From 636d6be69051f26eb9e9b8fc4a6a14e7bd04d774 Mon Sep 17 00:00:00 2001 From: Ritel-T Date: Tue, 1 Sep 2026 04:31:30 +0900 Subject: [PATCH 05/13] Expose free Fast in admin requests --- backend/internal/handler/admin/group_handler.go | 4 ++++ .../handler/admin/group_handler_openai_fast_test.go | 8 ++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go index 13f7d2fd8c..1f5f479a9f 100644 --- a/backend/internal/handler/admin/group_handler.go +++ b/backend/internal/handler/admin/group_handler.go @@ -148,6 +148,7 @@ type CreateGroupRequest struct { AllowMessagesDispatch bool `json:"allow_messages_dispatch"` AllowLive bool `json:"allow_live"` ForceOpenAIFast bool `json:"force_openai_fast"` + FreeOpenAIFast bool `json:"free_openai_fast"` RequireOAuthOnly bool `json:"require_oauth_only"` RequirePrivacySet bool `json:"require_privacy_set"` DefaultMappedModel string `json:"default_mapped_model"` @@ -218,6 +219,7 @@ type UpdateGroupRequest struct { AllowMessagesDispatch *bool `json:"allow_messages_dispatch"` AllowLive *bool `json:"allow_live"` ForceOpenAIFast *bool `json:"force_openai_fast"` + FreeOpenAIFast *bool `json:"free_openai_fast"` RequireOAuthOnly *bool `json:"require_oauth_only"` RequirePrivacySet *bool `json:"require_privacy_set"` DefaultMappedModel *string `json:"default_mapped_model"` @@ -553,6 +555,7 @@ func (h *GroupHandler) Create(c *gin.Context) { AllowMessagesDispatch: req.AllowMessagesDispatch, AllowLive: req.AllowLive, ForceOpenAIFast: req.ForceOpenAIFast, + FreeOpenAIFast: req.FreeOpenAIFast, RequireOAuthOnly: req.RequireOAuthOnly, RequirePrivacySet: req.RequirePrivacySet, DefaultMappedModel: req.DefaultMappedModel, @@ -683,6 +686,7 @@ func (h *GroupHandler) Update(c *gin.Context) { AllowMessagesDispatch: req.AllowMessagesDispatch, AllowLive: req.AllowLive, ForceOpenAIFast: req.ForceOpenAIFast, + FreeOpenAIFast: req.FreeOpenAIFast, RequireOAuthOnly: req.RequireOAuthOnly, RequirePrivacySet: req.RequirePrivacySet, DefaultMappedModel: req.DefaultMappedModel, diff --git a/backend/internal/handler/admin/group_handler_openai_fast_test.go b/backend/internal/handler/admin/group_handler_openai_fast_test.go index 22a9a53500..54ff52f9f9 100644 --- a/backend/internal/handler/admin/group_handler_openai_fast_test.go +++ b/backend/internal/handler/admin/group_handler_openai_fast_test.go @@ -9,15 +9,19 @@ import ( func TestGroupRequestsDecodeForceOpenAIFast(t *testing.T) { var createReq CreateGroupRequest - require.NoError(t, json.Unmarshal([]byte(`{"name":"fast","force_openai_fast":true}`), &createReq)) + require.NoError(t, json.Unmarshal([]byte(`{"name":"fast","force_openai_fast":true,"free_openai_fast":true}`), &createReq)) require.True(t, createReq.ForceOpenAIFast) + require.True(t, createReq.FreeOpenAIFast) var updateReq UpdateGroupRequest - require.NoError(t, json.Unmarshal([]byte(`{"force_openai_fast":false}`), &updateReq)) + require.NoError(t, json.Unmarshal([]byte(`{"force_openai_fast":false,"free_openai_fast":false}`), &updateReq)) require.NotNil(t, updateReq.ForceOpenAIFast) require.False(t, *updateReq.ForceOpenAIFast) + require.NotNil(t, updateReq.FreeOpenAIFast) + require.False(t, *updateReq.FreeOpenAIFast) var omitted UpdateGroupRequest require.NoError(t, json.Unmarshal([]byte(`{}`), &omitted)) require.Nil(t, omitted.ForceOpenAIFast) + require.Nil(t, omitted.FreeOpenAIFast) } From 08d6c153b79b13ee0d4dde26b984808bb6a510e2 Mon Sep 17 00:00:00 2001 From: Ritel-T Date: Tue, 1 Sep 2026 04:31:32 +0900 Subject: [PATCH 06/13] Return free Fast in admin responses --- backend/internal/handler/dto/group_mapper_openai_fast_test.go | 4 +++- backend/internal/handler/dto/mappers.go | 1 + backend/internal/handler/dto/types.go | 2 ++ 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/backend/internal/handler/dto/group_mapper_openai_fast_test.go b/backend/internal/handler/dto/group_mapper_openai_fast_test.go index c5aee931dc..6ad07b061f 100644 --- a/backend/internal/handler/dto/group_mapper_openai_fast_test.go +++ b/backend/internal/handler/dto/group_mapper_openai_fast_test.go @@ -10,14 +10,16 @@ import ( func TestGroupMapperExposesForceOpenAIFastOnlyToAdmins(t *testing.T) { group := &service.Group{ - ID: 7, Name: "fast", Platform: service.PlatformOpenAI, Status: service.StatusActive, ForceOpenAIFast: true, + ID: 7, Name: "fast", Platform: service.PlatformOpenAI, Status: service.StatusActive, ForceOpenAIFast: true, FreeOpenAIFast: true, } userJSON, err := json.Marshal(GroupFromService(group)) require.NoError(t, err) require.NotContains(t, string(userJSON), "force_openai_fast") + require.NotContains(t, string(userJSON), "free_openai_fast") adminJSON, err := json.Marshal(GroupFromServiceAdmin(group)) require.NoError(t, err) require.Contains(t, string(adminJSON), `"force_openai_fast":true`) + require.Contains(t, string(adminJSON), `"free_openai_fast":true`) } diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index 63aa423cbb..9f200de152 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -149,6 +149,7 @@ func GroupFromServiceAdmin(g *service.Group) *AdminGroup { out := &AdminGroup{ Group: groupFromServiceBase(g), ForceOpenAIFast: g.ForceOpenAIFast, + FreeOpenAIFast: g.FreeOpenAIFast, ProfitControlEnabled: g.ProfitControlEnabled, ProfitMinMargin: g.ProfitMinMargin, ProfitSafetyBuffer: g.ProfitSafetyBuffer, diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index f07592c2ca..95ca33eca3 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -166,6 +166,8 @@ type AdminGroup struct { Group // ForceOpenAIFast 是管理端请求策略,用户侧分组 DTO 无需暴露。 ForceOpenAIFast bool `json:"force_openai_fast"` + // FreeOpenAIFast 是管理端计费策略,用户侧分组 DTO 无需暴露。 + FreeOpenAIFast bool `json:"free_openai_fast"` // 分组利润控制(五个 token 平台分组可启用;margin/buffer 为小数存储)。 // 仅管理员可见:这三个字段与同响应中的 rate_multiplier 相乘即可反推出 From fd55f3248f29e96b02624e8bd89764e8b0cbbd43 Mon Sep 17 00:00:00 2001 From: Ritel-T Date: Tue, 1 Sep 2026 04:31:33 +0900 Subject: [PATCH 07/13] Manage free Fast through group lifecycle --- backend/internal/service/admin_group.go | 5 +++++ backend/internal/service/admin_service.go | 2 ++ backend/internal/service/admin_service_group_test.go | 8 ++++++-- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/backend/internal/service/admin_group.go b/backend/internal/service/admin_group.go index 032c7024bc..9275cf3df7 100644 --- a/backend/internal/service/admin_group.go +++ b/backend/internal/service/admin_group.go @@ -303,6 +303,7 @@ func sanitizeGroupOpenAIFast(group *Group) { if group == nil || !groupSupportsOpenAIFast(group.Platform) { if group != nil { group.ForceOpenAIFast = false + group.FreeOpenAIFast = false } } } @@ -513,6 +514,7 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn AllowMessagesDispatch: input.AllowMessagesDispatch, AllowLive: input.AllowLive, ForceOpenAIFast: input.ForceOpenAIFast, + FreeOpenAIFast: input.FreeOpenAIFast, RequireOAuthOnly: input.RequireOAuthOnly, RequirePrivacySet: input.RequirePrivacySet, DefaultMappedModel: input.DefaultMappedModel, @@ -879,6 +881,9 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd if input.ForceOpenAIFast != nil { group.ForceOpenAIFast = *input.ForceOpenAIFast } + if input.FreeOpenAIFast != nil { + group.FreeOpenAIFast = *input.FreeOpenAIFast + } if input.RequireOAuthOnly != nil { group.RequireOAuthOnly = *input.RequireOAuthOnly } diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 0195bc01c7..3392e79f15 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -267,6 +267,7 @@ type CreateGroupInput struct { AllowMessagesDispatch bool AllowLive bool ForceOpenAIFast bool + FreeOpenAIFast bool DefaultMappedModel string RequireOAuthOnly bool RequirePrivacySet bool @@ -343,6 +344,7 @@ type UpdateGroupInput struct { AllowMessagesDispatch *bool AllowLive *bool ForceOpenAIFast *bool + FreeOpenAIFast *bool DefaultMappedModel *string RequireOAuthOnly *bool RequirePrivacySet *bool diff --git a/backend/internal/service/admin_service_group_test.go b/backend/internal/service/admin_service_group_test.go index 02694cdfde..db34d09e99 100644 --- a/backend/internal/service/admin_service_group_test.go +++ b/backend/internal/service/admin_service_group_test.go @@ -1097,19 +1097,20 @@ func TestAdminService_CreateGroup_NormalizesForceOpenAIFastByPlatform(t *testing svc := &adminServiceImpl{groupRepo: repo} group, err := svc.CreateGroup(context.Background(), &CreateGroupInput{ - Name: "fast-" + tt.name, Platform: tt.platform, RateMultiplier: 1, ForceOpenAIFast: true, + Name: "fast-" + tt.name, Platform: tt.platform, RateMultiplier: 1, ForceOpenAIFast: true, FreeOpenAIFast: true, }) require.NoError(t, err) require.NotNil(t, group) require.Equal(t, tt.want, repo.created.ForceOpenAIFast) + require.Equal(t, tt.want, repo.created.FreeOpenAIFast) }) } } func TestAdminService_UpdateGroup_ClearsForceOpenAIFastWhenPlatformChanges(t *testing.T) { existingGroup := &Group{ - ID: 1, Name: "existing-fast", Platform: PlatformOpenAI, Status: StatusActive, ForceOpenAIFast: true, + ID: 1, Name: "existing-fast", Platform: PlatformOpenAI, Status: StatusActive, ForceOpenAIFast: true, FreeOpenAIFast: true, } repo := &groupRepoStubForAdmin{getByID: existingGroup} svc := &adminServiceImpl{groupRepo: repo} @@ -1121,6 +1122,7 @@ func TestAdminService_UpdateGroup_ClearsForceOpenAIFastWhenPlatformChanges(t *te require.NoError(t, err) require.NotNil(t, group) require.False(t, repo.updated.ForceOpenAIFast) + require.False(t, repo.updated.FreeOpenAIFast) } func TestAdminService_UpdateGroup_ForceOpenAIFastInvalidatesAuthCache(t *testing.T) { @@ -1134,11 +1136,13 @@ func TestAdminService_UpdateGroup_ForceOpenAIFastInvalidatesAuthCache(t *testing group, err := svc.UpdateGroup(context.Background(), existingGroup.ID, &UpdateGroupInput{ ForceOpenAIFast: &enabled, + FreeOpenAIFast: &enabled, }) require.NoError(t, err) require.NotNil(t, group) require.True(t, repo.updated.ForceOpenAIFast) + require.True(t, repo.updated.FreeOpenAIFast) require.Equal(t, []int64{existingGroup.ID}, invalidator.groupIDs) } From 8224434d218e87447962044d67fd6accbbb2ef13 Mon Sep 17 00:00:00 2001 From: Ritel-T Date: Tue, 1 Sep 2026 04:31:35 +0900 Subject: [PATCH 08/13] Preserve free Fast when duplicating groups --- backend/internal/service/admin_group_duplicate.go | 1 + backend/internal/service/admin_group_duplicate_test.go | 2 ++ 2 files changed, 3 insertions(+) diff --git a/backend/internal/service/admin_group_duplicate.go b/backend/internal/service/admin_group_duplicate.go index 09fcbb8ec7..e1c4165b0f 100644 --- a/backend/internal/service/admin_group_duplicate.go +++ b/backend/internal/service/admin_group_duplicate.go @@ -145,6 +145,7 @@ func cloneGroupForDuplicate(source *Group, operationID string) *Group { AllowMessagesDispatch: source.AllowMessagesDispatch, AllowLive: source.AllowLive, ForceOpenAIFast: source.ForceOpenAIFast, + FreeOpenAIFast: source.FreeOpenAIFast, RequireOAuthOnly: source.RequireOAuthOnly, RequirePrivacySet: source.RequirePrivacySet, DefaultMappedModel: source.DefaultMappedModel, diff --git a/backend/internal/service/admin_group_duplicate_test.go b/backend/internal/service/admin_group_duplicate_test.go index 4a13c53fce..f33ad8bc8b 100644 --- a/backend/internal/service/admin_group_duplicate_test.go +++ b/backend/internal/service/admin_group_duplicate_test.go @@ -167,6 +167,7 @@ func TestDuplicateGroupCopiesConfigurationDeeplyAndResetsRuntimeState(t *testing AllowMessagesDispatch: true, AllowLive: true, ForceOpenAIFast: true, + FreeOpenAIFast: true, RequireOAuthOnly: true, RequirePrivacySet: true, DefaultMappedModel: "gpt-5.4", @@ -214,6 +215,7 @@ func TestDuplicateGroupCopiesConfigurationDeeplyAndResetsRuntimeState(t *testing require.Equal(t, source.ModelRouting, duplicate.ModelRouting) require.Equal(t, source.MessagesDispatchModelConfig, duplicate.MessagesDispatchModelConfig) require.Equal(t, source.ForceOpenAIFast, duplicate.ForceOpenAIFast) + require.Equal(t, source.FreeOpenAIFast, duplicate.FreeOpenAIFast) require.Equal(t, source.ModelsListConfig, duplicate.ModelsListConfig) require.Equal(t, source.RPMLimit, duplicate.RPMLimit) require.Equal(t, source.MaxReasoningEffort, duplicate.MaxReasoningEffort) From e619ca386eef8962d8b8edea431ff837a361fc45 Mon Sep 17 00:00:00 2001 From: Ritel-T Date: Tue, 1 Sep 2026 04:31:36 +0900 Subject: [PATCH 09/13] Carry free Fast through auth snapshots --- backend/internal/service/api_key_auth_cache.go | 1 + backend/internal/service/api_key_auth_cache_impl.go | 4 +++- .../internal/service/api_key_auth_cache_openai_fast_test.go | 5 +++-- backend/internal/service/api_key_auth_cache_profit_test.go | 2 +- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/backend/internal/service/api_key_auth_cache.go b/backend/internal/service/api_key_auth_cache.go index 0bdbfc8293..be08213d17 100644 --- a/backend/internal/service/api_key_auth_cache.go +++ b/backend/internal/service/api_key_auth_cache.go @@ -104,6 +104,7 @@ type APIKeyAuthGroupSnapshot struct { AllowMessagesDispatch bool `json:"allow_messages_dispatch"` AllowLive bool `json:"allow_live"` ForceOpenAIFast bool `json:"force_openai_fast"` + FreeOpenAIFast bool `json:"free_openai_fast"` DefaultMappedModel string `json:"default_mapped_model,omitempty"` MessagesDispatchModelConfig OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config,omitempty"` ModelsListConfig GroupModelsListConfig `json:"models_list_config,omitempty"` diff --git a/backend/internal/service/api_key_auth_cache_impl.go b/backend/internal/service/api_key_auth_cache_impl.go index f267a3fe89..800c888f68 100644 --- a/backend/internal/service/api_key_auth_cache_impl.go +++ b/backend/internal/service/api_key_auth_cache_impl.go @@ -14,7 +14,7 @@ import ( "github.com/dgraph-io/ristretto" ) -const apiKeyAuthSnapshotVersion = 21 // v21: group force_openai_fast field +const apiKeyAuthSnapshotVersion = 22 // v22: group free_openai_fast field type apiKeyAuthCacheConfig struct { l1Size int @@ -419,6 +419,7 @@ func (s *APIKeyService) snapshotFromAPIKey(ctx context.Context, apiKey *APIKey) AllowMessagesDispatch: apiKey.Group.AllowMessagesDispatch, AllowLive: apiKey.Group.AllowLive, ForceOpenAIFast: apiKey.Group.ForceOpenAIFast, + FreeOpenAIFast: apiKey.Group.FreeOpenAIFast, DefaultMappedModel: apiKey.Group.DefaultMappedModel, MessagesDispatchModelConfig: apiKey.Group.MessagesDispatchModelConfig, ModelsListConfig: apiKey.Group.ModelsListConfig, @@ -518,6 +519,7 @@ func (s *APIKeyService) snapshotToAPIKey(key string, snapshot *APIKeyAuthSnapsho AllowMessagesDispatch: snapshot.Group.AllowMessagesDispatch, AllowLive: snapshot.Group.AllowLive, ForceOpenAIFast: snapshot.Group.ForceOpenAIFast, + FreeOpenAIFast: snapshot.Group.FreeOpenAIFast, DefaultMappedModel: snapshot.Group.DefaultMappedModel, MessagesDispatchModelConfig: snapshot.Group.MessagesDispatchModelConfig, ModelsListConfig: snapshot.Group.ModelsListConfig, diff --git a/backend/internal/service/api_key_auth_cache_openai_fast_test.go b/backend/internal/service/api_key_auth_cache_openai_fast_test.go index 18a1d3ca9d..07e906e4e7 100644 --- a/backend/internal/service/api_key_auth_cache_openai_fast_test.go +++ b/backend/internal/service/api_key_auth_cache_openai_fast_test.go @@ -15,7 +15,7 @@ func TestAPIKeyAuthSnapshotGroupForceOpenAIFastRoundtrip(t *testing.T) { User: &User{ID: 40, Status: StatusActive}, Group: &Group{ ID: groupID, Name: "fast-roundtrip", Platform: PlatformOpenAI, Status: StatusActive, - Hydrated: true, ForceOpenAIFast: true, + Hydrated: true, ForceOpenAIFast: true, FreeOpenAIFast: true, }, } svc := &APIKeyService{} @@ -31,5 +31,6 @@ func TestAPIKeyAuthSnapshotGroupForceOpenAIFastRoundtrip(t *testing.T) { require.NotNil(t, materialized.Group) require.True(t, materialized.Group.Hydrated) require.True(t, materialized.Group.ForceOpenAIFast) - require.Equal(t, 21, cached.Snapshot.Version) + require.True(t, materialized.Group.FreeOpenAIFast) + require.Equal(t, 22, cached.Snapshot.Version) } diff --git a/backend/internal/service/api_key_auth_cache_profit_test.go b/backend/internal/service/api_key_auth_cache_profit_test.go index d69d21d869..51347e0eab 100644 --- a/backend/internal/service/api_key_auth_cache_profit_test.go +++ b/backend/internal/service/api_key_auth_cache_profit_test.go @@ -53,7 +53,7 @@ func TestAPIKeyAuthSnapshotProfitControlRoundtrip(t *testing.T) { snapshot := svc.snapshotFromAPIKey(context.Background(), apiKey) require.NotNil(t, snapshot) require.Equal(t, apiKeyAuthSnapshotVersion, snapshot.Version) - require.Equal(t, 21, snapshot.Version, "v21 起认证快照携带分组 Fast 强制开关") + require.Equal(t, 22, snapshot.Version, "v22 起认证快照携带分组免费 Fast 开关") // 模拟 L2 缓存的完整 JSON 往返(与 apiKeyCache.SetAuthCache/GetAuthCache 同构)。 payload, err := json.Marshal(&APIKeyAuthCacheEntry{Snapshot: snapshot}) From e6722126ba904d6515cfa27ed35ea32b5dc2dad2 Mon Sep 17 00:00:00 2001 From: Ritel-T Date: Tue, 1 Sep 2026 04:32:01 +0900 Subject: [PATCH 10/13] Add frontend free Fast types --- frontend/src/types/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index ae86a3be2c..018d054f5d 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -612,6 +612,7 @@ export interface Group { export interface AdminGroup extends Group { force_openai_fast: boolean + free_openai_fast: boolean model_pricing: import('@/api/admin/channels').ChannelModelPricing[] // 分组利润控制(openai/anthropic/gemini/grok/antigravity 分组可启用;margin/buffer 为小数存储)。 // 仅管理员可见:与 rate_multiplier 相乘即可反推上游成本上限,不得下放到 Group。 @@ -777,6 +778,7 @@ export interface CreateGroupRequest { monthly_limit_usd?: number | null long_context_pricing_enabled?: boolean force_openai_fast?: boolean + free_openai_fast?: boolean model_pricing?: import('@/api/admin/channels').ChannelModelPricing[] allow_image_generation?: boolean allow_batch_image_generation?: boolean @@ -840,6 +842,7 @@ export interface UpdateGroupRequest { monthly_limit_usd?: number | null long_context_pricing_enabled?: boolean force_openai_fast?: boolean + free_openai_fast?: boolean model_pricing?: import('@/api/admin/channels').ChannelModelPricing[] allow_image_generation?: boolean allow_batch_image_generation?: boolean From 498e06c5821d5e7cf9afde50d28344cba5a41407 Mon Sep 17 00:00:00 2001 From: Ritel-T Date: Tue, 1 Sep 2026 04:32:03 +0900 Subject: [PATCH 11/13] Localize the free Fast control --- frontend/src/i18n/locales/en/admin/overview.ts | 4 +++- frontend/src/i18n/locales/zh/admin/overview.ts | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/frontend/src/i18n/locales/en/admin/overview.ts b/frontend/src/i18n/locales/en/admin/overview.ts index 41a7781c8c..87c38ebe4e 100644 --- a/frontend/src/i18n/locales/en/admin/overview.ts +++ b/frontend/src/i18n/locales/en/admin/overview.ts @@ -1154,7 +1154,9 @@ export default { openaiFast: { title: 'OpenAI Fast mode', force: 'Force Fast (priority)', - hint: 'Forces service_tier=priority on OpenAI requests in this group. The global Fast/Flex policy can still filter or block it. New requests update immediately after saving; existing WebSocket sessions must reconnect.' + hint: 'Forces service_tier=priority on OpenAI requests in this group. The global Fast/Flex policy can still filter or block it. New requests update immediately after saving; existing WebSocket sessions must reconnect.', + free: 'Free Fast', + freeHint: 'Fast requests in this group still use the priority tier, but customers are charged the equivalent Standard price.' }, invalidRequestFallback: { title: 'Invalid Request Fallback Group', diff --git a/frontend/src/i18n/locales/zh/admin/overview.ts b/frontend/src/i18n/locales/zh/admin/overview.ts index 27cf6fdfd7..d4bb80c9bf 100644 --- a/frontend/src/i18n/locales/zh/admin/overview.ts +++ b/frontend/src/i18n/locales/zh/admin/overview.ts @@ -1152,7 +1152,9 @@ export default { openaiFast: { title: 'OpenAI Fast 模式', force: '强制使用 Fast(priority)', - hint: '开启后,此分组的 OpenAI 请求会强制写入 service_tier=priority;全局 Fast/Flex 策略仍可过滤或拦截。保存后新请求立即生效,已建立的 WebSocket 会话需重连。' + hint: '开启后,此分组的 OpenAI 请求会强制写入 service_tier=priority;全局 Fast/Flex 策略仍可过滤或拦截。保存后新请求立即生效,已建立的 WebSocket 会话需重连。', + free: '免费 Fast', + freeHint: '该分组的 Fast 请求仍使用 priority 档位,但客户实际费用按同一请求的 Standard 价格计算。' }, invalidRequestFallback: { title: '无效请求兜底分组', From 4df6b0636bdf251142a83ebaf83831ba59f89e9b Mon Sep 17 00:00:00 2001 From: Ritel-T Date: Tue, 1 Sep 2026 04:32:04 +0900 Subject: [PATCH 12/13] Add the free Fast group switch --- frontend/src/views/admin/GroupsView.vue | 71 +++++++++++++++++++ .../admin/__tests__/groupsOpenAIFast.spec.ts | 4 ++ 2 files changed, 75 insertions(+) diff --git a/frontend/src/views/admin/GroupsView.vue b/frontend/src/views/admin/GroupsView.vue index 040cf22ef6..62bb9998ca 100644 --- a/frontend/src/views/admin/GroupsView.vue +++ b/frontend/src/views/admin/GroupsView.vue @@ -1618,6 +1618,35 @@

{{ t("admin.groups.openaiFast.hint") }}

+
+ + +
+

+ {{ t("admin.groups.openaiFast.freeHint") }} +

@@ -3385,6 +3414,35 @@

{{ t("admin.groups.openaiFast.hint") }}

+
+ + +
+

+ {{ t("admin.groups.openaiFast.freeHint") }} +

@@ -5130,6 +5188,7 @@ const createForm = reactive({ monthly_limit_usd: null as number | null, long_context_pricing_enabled: true, force_openai_fast: false, + free_openai_fast: false, model_pricing: [] as PricingFormEntry[], // 图片生成计费配置 allow_image_generation: false, @@ -5492,6 +5551,7 @@ const editForm = reactive({ monthly_limit_usd: null as number | null, long_context_pricing_enabled: true, force_openai_fast: false, + free_openai_fast: false, model_pricing: [] as PricingFormEntry[], // 图片生成计费配置 allow_image_generation: false, @@ -5967,6 +6027,7 @@ const closeCreateModal = () => { createForm.video_model_prices = createVideoModelPricesForm(); createForm.long_context_pricing_enabled = true; createForm.force_openai_fast = false; + createForm.free_openai_fast = false; createForm.model_pricing = []; createForm.web_search_price_per_call = null; createForm.search_price_per_1k = null; @@ -6071,6 +6132,10 @@ const handleCreateGroup = async () => { createForm.platform, createForm.force_openai_fast, ), + free_openai_fast: normalizeGroupOpenAIFast( + createForm.platform, + createForm.free_openai_fast, + ), model_pricing: groupPricingToAPI( createForm.model_pricing, createForm.platform, @@ -6200,6 +6265,7 @@ const handleEdit = async (group: AdminGroup) => { editForm.long_context_pricing_enabled = group.long_context_pricing_enabled ?? true; editForm.force_openai_fast = group.force_openai_fast ?? false; + editForm.free_openai_fast = group.free_openai_fast ?? false; editForm.model_pricing = groupPricingFromAPI(group.model_pricing); editForm.allow_image_generation = group.allow_image_generation ?? false; editForm.allow_batch_image_generation = @@ -6307,6 +6373,7 @@ const closeEditModal = () => { editForm.video_model_prices = createVideoModelPricesForm(); editForm.long_context_pricing_enabled = true; editForm.force_openai_fast = false; + editForm.free_openai_fast = false; editForm.model_pricing = []; editForm.web_search_price_per_call = null; editForm.search_price_per_1k = null; @@ -6344,6 +6411,10 @@ const handleUpdateGroup = async () => { editForm.platform, editForm.force_openai_fast, ), + free_openai_fast: normalizeGroupOpenAIFast( + editForm.platform, + editForm.free_openai_fast, + ), model_pricing: groupPricingToAPI( editForm.model_pricing, editForm.platform, diff --git a/frontend/src/views/admin/__tests__/groupsOpenAIFast.spec.ts b/frontend/src/views/admin/__tests__/groupsOpenAIFast.spec.ts index e94ee5a36c..a0333e0a5a 100644 --- a/frontend/src/views/admin/__tests__/groupsOpenAIFast.spec.ts +++ b/frontend/src/views/admin/__tests__/groupsOpenAIFast.spec.ts @@ -26,11 +26,15 @@ describe("groupsOpenAIFast", () => { title: expect.any(String), force: expect.any(String), hint: expect.stringContaining("service_tier=priority"), + free: expect.any(String), + freeHint: expect.stringContaining("Standard"), }); expect(en.groups.openaiFast).toMatchObject({ title: expect.any(String), force: expect.any(String), hint: expect.stringContaining("service_tier=priority"), + free: expect.any(String), + freeHint: expect.stringContaining("Standard"), }); }); }); From 9908d3ca2fe44b004ec2242806e5865cc09986e2 Mon Sep 17 00:00:00 2001 From: Ritel-T Date: Tue, 1 Sep 2026 04:32:06 +0900 Subject: [PATCH 13/13] Bill free Fast requests at Standard price --- .../openai_gateway_record_usage_test.go | 71 +++++++++++++++++++ .../internal/service/openai_gateway_usage.go | 44 ++++++++++++ 2 files changed, 115 insertions(+) diff --git a/backend/internal/service/openai_gateway_record_usage_test.go b/backend/internal/service/openai_gateway_record_usage_test.go index b65f55c71c..8b6497a2cb 100644 --- a/backend/internal/service/openai_gateway_record_usage_test.go +++ b/backend/internal/service/openai_gateway_record_usage_test.go @@ -3071,6 +3071,77 @@ func TestOpenAIGatewayServiceRecordUsage_ShadowUsesParentCredentialTierContract( require.InDelta(t, fastCost.TotalCost, usageRepo.lastLog.TotalCost, 1e-10) } +func TestOpenAIGatewayServiceRecordUsage_FreeOpenAIFastChargesStandard(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + svc := newOpenAIRecordUsageServiceForTest( + usageRepo, + &openAIRecordUsageUserRepoStub{}, + &openAIRecordUsageSubRepoStub{}, + nil, + ) + svc.resolver = NewModelPricingResolver(nil, svc.billingService) + groupID := int64(77) + serviceTier := "priority" + tokens := UsageTokens{InputTokens: 100, OutputTokens: 50} + inputPrice := 0.001 + outputPrice := 0.002 + fastMultiplier := 3.0 + apiKey := &APIKey{ + ID: 1020, + GroupID: &groupID, + Group: &Group{ + ID: groupID, Platform: PlatformOpenAI, Status: StatusActive, + Hydrated: true, RateMultiplier: 0.5, FreeOpenAIFast: true, + ModelPricing: []ChannelModelPricing{{ + Models: []string{"gpt-5.6-sol"}, + BillingMode: BillingModeToken, + InputPrice: &inputPrice, + OutputPrice: &outputPrice, + FastMultiplier: &fastMultiplier, + }}, + }, + } + + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "resp_free_fast", + ServiceTier: &serviceTier, + UpstreamResponseServiceTier: "default", + Usage: OpenAIUsage{InputTokens: tokens.InputTokens, OutputTokens: tokens.OutputTokens}, + Model: "gpt-5.6-sol", + Duration: time.Second, + }, + APIKey: apiKey, + User: &User{ID: 2020}, + Account: &Account{ID: 3020, Platform: PlatformOpenAI, Type: AccountTypeOAuth}, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.NotNil(t, usageRepo.lastLog.ServiceTier) + require.Equal(t, "priority", *usageRepo.lastLog.ServiceTier) + require.InDelta(t, 0.5, usageRepo.lastLog.RateMultiplier, 1e-12) + + standardTotal := float64(tokens.InputTokens)*inputPrice + float64(tokens.OutputTokens)*outputPrice + require.InDelta(t, standardTotal*fastMultiplier, usageRepo.lastLog.TotalCost, 1e-10) + require.InDelta(t, standardTotal*0.5, usageRepo.lastLog.ActualCost, 1e-10) +} + +func TestGroupBillsOpenAIFastAtStandardRequiresOpenAIAccount(t *testing.T) { + apiKey := &APIKey{Group: &Group{Platform: PlatformComposite, FreeOpenAIFast: true}} + + require.True(t, groupBillsOpenAIFastAtStandard( + apiKey, + &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}, + "priority", + )) + require.False(t, groupBillsOpenAIFastAtStandard( + apiKey, + &Account{Platform: PlatformGrok, Type: AccountTypeAPIKey}, + "priority", + )) +} + func TestOpenAIGatewayServiceRecordUsage_ServiceTierNeverRaisedByUpstreamResponse(t *testing.T) { usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} userRepo := &openAIRecordUsageUserRepoStub{} diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go index 6fb688353a..cb998b55fa 100644 --- a/backend/internal/service/openai_gateway_usage.go +++ b/backend/internal/service/openai_gateway_usage.go @@ -132,6 +132,24 @@ func openAIUsagePricingAt(input *OpenAIRecordUsageInput) time.Time { return timezone.Now() } +func groupBillsOpenAIFastAtStandard(apiKey *APIKey, account *Account, serviceTier string) bool { + if apiKey == nil || apiKey.Group == nil || !apiKey.Group.FreeOpenAIFast { + return false + } + if account == nil || !account.IsOpenAI() { + return false + } + if !groupSupportsOpenAIFast(apiKey.Group.Platform) { + return false + } + switch normalizeBillingServiceTier(serviceTier) { + case "priority", "fast": + return true + default: + return false + } +} + // RecordUsage records usage and deducts balance func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRecordUsageInput) error { if input == nil { @@ -277,6 +295,32 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec } } + // Free Fast changes only the customer charge. Keep priority TotalCost and + // service_tier for upstream accounting, but evaluate ActualCost once more at + // the Standard tier using the same channel, peak, and long-context policy. + if groupBillsOpenAIFastAtStandard(apiKey, billingAccount, serviceTier) { + standardCost, standardErr := s.calculateOpenAIRecordUsageCost( + ctx, + result, + apiKey, + billingModels, + multiplier, + imageMultiplier, + videoMultiplier, + baseMultiplier, + tokens, + "", + longContextBillingGate, + pricingAt, + ) + if standardErr != nil { + return standardErr + } + if cost != nil && standardCost != nil { + cost.ActualCost = standardCost.ActualCost + } + } + // Determine billing type isSubscriptionBilling := subscription != nil && apiKey.Group != nil && apiKey.Group.IsSubscriptionType() billingType := BillingTypeBalance