From 92d35409de087816a36d1771c3e0ed8597a0fd84 Mon Sep 17 00:00:00 2001 From: shaw Date: Sat, 7 Mar 2026 17:02:19 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=B8=BAopenai=E5=88=86=E7=BB=84?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0messages=E8=B0=83=E5=BA=A6=E5=BC=80=E5=85=B3?= =?UTF-8?q?=E5=92=8C=E9=BB=98=E8=AE=A4=E6=98=A0=E5=B0=84=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/ent/group.go | 26 +++- backend/ent/group/group.go | 22 +++ backend/ent/group/where.go | 85 +++++++++++ backend/ent/group_create.go | 135 ++++++++++++++++++ backend/ent/group_update.go | 78 ++++++++++ backend/ent/migrate/schema.go | 2 + backend/ent/mutation.go | 110 +++++++++++++- backend/ent/runtime/runtime.go | 10 ++ backend/ent/schema/group.go | 9 ++ .../internal/handler/admin/group_handler.go | 10 ++ backend/internal/handler/dto/mappers.go | 6 +- backend/internal/handler/dto/types.go | 6 + .../handler/openai_gateway_handler.go | 59 ++++++-- backend/internal/repository/api_key_repo.go | 4 + backend/internal/repository/group_repo.go | 8 +- backend/internal/service/admin_service.go | 16 +++ .../internal/service/api_key_auth_cache.go | 4 + .../service/api_key_auth_cache_impl.go | 4 + backend/internal/service/group.go | 4 + .../service/openai_gateway_messages.go | 5 + .../069_add_group_messages_dispatch.sql | 2 + frontend/src/components/keys/UseKeyModal.vue | 13 +- frontend/src/i18n/locales/en.ts | 8 ++ frontend/src/i18n/locales/zh.ts | 8 ++ frontend/src/types/index.ts | 5 + frontend/src/views/admin/GroupsView.vue | 90 ++++++++++++ frontend/src/views/user/KeysView.vue | 1 + 27 files changed, 711 insertions(+), 19 deletions(-) create mode 100644 backend/migrations/069_add_group_messages_dispatch.sql diff --git a/backend/ent/group.go b/backend/ent/group.go index 76c3cae2..3db54a64 100644 --- a/backend/ent/group.go +++ b/backend/ent/group.go @@ -78,6 +78,10 @@ type Group struct { SupportedModelScopes []string `json:"supported_model_scopes,omitempty"` // 分组显示排序,数值越小越靠前 SortOrder int `json:"sort_order,omitempty"` + // 是否允许 /v1/messages 调度到此 OpenAI 分组 + AllowMessagesDispatch bool `json:"allow_messages_dispatch,omitempty"` + // 默认映射模型 ID,当账号级映射找不到时使用此值 + DefaultMappedModel string `json:"default_mapped_model,omitempty"` // Edges holds the relations/edges for other nodes in the graph. // The values are being populated by the GroupQuery when eager-loading is set. Edges GroupEdges `json:"edges"` @@ -186,13 +190,13 @@ func (*Group) scanValues(columns []string) ([]any, error) { switch columns[i] { case group.FieldModelRouting, group.FieldSupportedModelScopes: values[i] = new([]byte) - case group.FieldIsExclusive, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject: + case group.FieldIsExclusive, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch: values[i] = new(sql.NullBool) case group.FieldRateMultiplier, group.FieldDailyLimitUsd, group.FieldWeeklyLimitUsd, group.FieldMonthlyLimitUsd, group.FieldImagePrice1k, group.FieldImagePrice2k, group.FieldImagePrice4k, group.FieldSoraImagePrice360, group.FieldSoraImagePrice540, group.FieldSoraVideoPricePerRequest, group.FieldSoraVideoPricePerRequestHd: values[i] = new(sql.NullFloat64) case group.FieldID, group.FieldDefaultValidityDays, group.FieldSoraStorageQuotaBytes, group.FieldFallbackGroupID, group.FieldFallbackGroupIDOnInvalidRequest, group.FieldSortOrder: values[i] = new(sql.NullInt64) - case group.FieldName, group.FieldDescription, group.FieldStatus, group.FieldPlatform, group.FieldSubscriptionType: + case group.FieldName, group.FieldDescription, group.FieldStatus, group.FieldPlatform, group.FieldSubscriptionType, group.FieldDefaultMappedModel: values[i] = new(sql.NullString) case group.FieldCreatedAt, group.FieldUpdatedAt, group.FieldDeletedAt: values[i] = new(sql.NullTime) @@ -415,6 +419,18 @@ func (_m *Group) assignValues(columns []string, values []any) error { } else if value.Valid { _m.SortOrder = int(value.Int64) } + case group.FieldAllowMessagesDispatch: + if value, ok := values[i].(*sql.NullBool); !ok { + return fmt.Errorf("unexpected type %T for field allow_messages_dispatch", values[i]) + } else if value.Valid { + _m.AllowMessagesDispatch = value.Bool + } + case group.FieldDefaultMappedModel: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field default_mapped_model", values[i]) + } else if value.Valid { + _m.DefaultMappedModel = value.String + } default: _m.selectValues.Set(columns[i], values[i]) } @@ -608,6 +624,12 @@ func (_m *Group) String() string { builder.WriteString(", ") builder.WriteString("sort_order=") builder.WriteString(fmt.Sprintf("%v", _m.SortOrder)) + builder.WriteString(", ") + builder.WriteString("allow_messages_dispatch=") + builder.WriteString(fmt.Sprintf("%v", _m.AllowMessagesDispatch)) + builder.WriteString(", ") + builder.WriteString("default_mapped_model=") + builder.WriteString(_m.DefaultMappedModel) builder.WriteByte(')') return builder.String() } diff --git a/backend/ent/group/group.go b/backend/ent/group/group.go index 6ac4eea1..2612b6cf 100644 --- a/backend/ent/group/group.go +++ b/backend/ent/group/group.go @@ -75,6 +75,10 @@ const ( FieldSupportedModelScopes = "supported_model_scopes" // FieldSortOrder holds the string denoting the sort_order field in the database. FieldSortOrder = "sort_order" + // FieldAllowMessagesDispatch holds the string denoting the allow_messages_dispatch field in the database. + FieldAllowMessagesDispatch = "allow_messages_dispatch" + // FieldDefaultMappedModel holds the string denoting the default_mapped_model field in the database. + FieldDefaultMappedModel = "default_mapped_model" // EdgeAPIKeys holds the string denoting the api_keys edge name in mutations. EdgeAPIKeys = "api_keys" // EdgeRedeemCodes holds the string denoting the redeem_codes edge name in mutations. @@ -180,6 +184,8 @@ var Columns = []string{ FieldMcpXMLInject, FieldSupportedModelScopes, FieldSortOrder, + FieldAllowMessagesDispatch, + FieldDefaultMappedModel, } var ( @@ -247,6 +253,12 @@ var ( DefaultSupportedModelScopes []string // DefaultSortOrder holds the default value on creation for the "sort_order" field. DefaultSortOrder int + // DefaultAllowMessagesDispatch holds the default value on creation for the "allow_messages_dispatch" field. + DefaultAllowMessagesDispatch bool + // DefaultDefaultMappedModel holds the default value on creation for the "default_mapped_model" field. + DefaultDefaultMappedModel string + // DefaultMappedModelValidator is a validator for the "default_mapped_model" field. It is called by the builders before save. + DefaultMappedModelValidator func(string) error ) // OrderOption defines the ordering options for the Group queries. @@ -397,6 +409,16 @@ func BySortOrder(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldSortOrder, opts...).ToFunc() } +// ByAllowMessagesDispatch orders the results by the allow_messages_dispatch field. +func ByAllowMessagesDispatch(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldAllowMessagesDispatch, opts...).ToFunc() +} + +// ByDefaultMappedModel orders the results by the default_mapped_model field. +func ByDefaultMappedModel(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldDefaultMappedModel, opts...).ToFunc() +} + // ByAPIKeysCount orders the results by api_keys count. func ByAPIKeysCount(opts ...sql.OrderTermOption) OrderOption { return func(s *sql.Selector) { diff --git a/backend/ent/group/where.go b/backend/ent/group/where.go index 4cf65d0f..5dd8759e 100644 --- a/backend/ent/group/where.go +++ b/backend/ent/group/where.go @@ -195,6 +195,16 @@ func SortOrder(v int) predicate.Group { return predicate.Group(sql.FieldEQ(FieldSortOrder, v)) } +// AllowMessagesDispatch applies equality check predicate on the "allow_messages_dispatch" field. It's identical to AllowMessagesDispatchEQ. +func AllowMessagesDispatch(v bool) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldAllowMessagesDispatch, v)) +} + +// DefaultMappedModel applies equality check predicate on the "default_mapped_model" field. It's identical to DefaultMappedModelEQ. +func DefaultMappedModel(v string) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldDefaultMappedModel, v)) +} + // CreatedAtEQ applies the EQ predicate on the "created_at" field. func CreatedAtEQ(v time.Time) predicate.Group { return predicate.Group(sql.FieldEQ(FieldCreatedAt, v)) @@ -1470,6 +1480,81 @@ func SortOrderLTE(v int) predicate.Group { return predicate.Group(sql.FieldLTE(FieldSortOrder, v)) } +// AllowMessagesDispatchEQ applies the EQ predicate on the "allow_messages_dispatch" field. +func AllowMessagesDispatchEQ(v bool) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldAllowMessagesDispatch, v)) +} + +// AllowMessagesDispatchNEQ applies the NEQ predicate on the "allow_messages_dispatch" field. +func AllowMessagesDispatchNEQ(v bool) predicate.Group { + return predicate.Group(sql.FieldNEQ(FieldAllowMessagesDispatch, v)) +} + +// DefaultMappedModelEQ applies the EQ predicate on the "default_mapped_model" field. +func DefaultMappedModelEQ(v string) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldDefaultMappedModel, v)) +} + +// DefaultMappedModelNEQ applies the NEQ predicate on the "default_mapped_model" field. +func DefaultMappedModelNEQ(v string) predicate.Group { + return predicate.Group(sql.FieldNEQ(FieldDefaultMappedModel, v)) +} + +// DefaultMappedModelIn applies the In predicate on the "default_mapped_model" field. +func DefaultMappedModelIn(vs ...string) predicate.Group { + return predicate.Group(sql.FieldIn(FieldDefaultMappedModel, vs...)) +} + +// DefaultMappedModelNotIn applies the NotIn predicate on the "default_mapped_model" field. +func DefaultMappedModelNotIn(vs ...string) predicate.Group { + return predicate.Group(sql.FieldNotIn(FieldDefaultMappedModel, vs...)) +} + +// DefaultMappedModelGT applies the GT predicate on the "default_mapped_model" field. +func DefaultMappedModelGT(v string) predicate.Group { + return predicate.Group(sql.FieldGT(FieldDefaultMappedModel, v)) +} + +// DefaultMappedModelGTE applies the GTE predicate on the "default_mapped_model" field. +func DefaultMappedModelGTE(v string) predicate.Group { + return predicate.Group(sql.FieldGTE(FieldDefaultMappedModel, v)) +} + +// DefaultMappedModelLT applies the LT predicate on the "default_mapped_model" field. +func DefaultMappedModelLT(v string) predicate.Group { + return predicate.Group(sql.FieldLT(FieldDefaultMappedModel, v)) +} + +// DefaultMappedModelLTE applies the LTE predicate on the "default_mapped_model" field. +func DefaultMappedModelLTE(v string) predicate.Group { + return predicate.Group(sql.FieldLTE(FieldDefaultMappedModel, v)) +} + +// DefaultMappedModelContains applies the Contains predicate on the "default_mapped_model" field. +func DefaultMappedModelContains(v string) predicate.Group { + return predicate.Group(sql.FieldContains(FieldDefaultMappedModel, v)) +} + +// DefaultMappedModelHasPrefix applies the HasPrefix predicate on the "default_mapped_model" field. +func DefaultMappedModelHasPrefix(v string) predicate.Group { + return predicate.Group(sql.FieldHasPrefix(FieldDefaultMappedModel, v)) +} + +// DefaultMappedModelHasSuffix applies the HasSuffix predicate on the "default_mapped_model" field. +func DefaultMappedModelHasSuffix(v string) predicate.Group { + return predicate.Group(sql.FieldHasSuffix(FieldDefaultMappedModel, v)) +} + +// DefaultMappedModelEqualFold applies the EqualFold predicate on the "default_mapped_model" field. +func DefaultMappedModelEqualFold(v string) predicate.Group { + return predicate.Group(sql.FieldEqualFold(FieldDefaultMappedModel, v)) +} + +// DefaultMappedModelContainsFold applies the ContainsFold predicate on the "default_mapped_model" field. +func DefaultMappedModelContainsFold(v string) predicate.Group { + return predicate.Group(sql.FieldContainsFold(FieldDefaultMappedModel, v)) +} + // HasAPIKeys applies the HasEdge predicate on the "api_keys" edge. func HasAPIKeys() predicate.Group { return predicate.Group(func(s *sql.Selector) { diff --git a/backend/ent/group_create.go b/backend/ent/group_create.go index 0ce5f959..6db5b974 100644 --- a/backend/ent/group_create.go +++ b/backend/ent/group_create.go @@ -424,6 +424,34 @@ func (_c *GroupCreate) SetNillableSortOrder(v *int) *GroupCreate { return _c } +// SetAllowMessagesDispatch sets the "allow_messages_dispatch" field. +func (_c *GroupCreate) SetAllowMessagesDispatch(v bool) *GroupCreate { + _c.mutation.SetAllowMessagesDispatch(v) + return _c +} + +// SetNillableAllowMessagesDispatch sets the "allow_messages_dispatch" field if the given value is not nil. +func (_c *GroupCreate) SetNillableAllowMessagesDispatch(v *bool) *GroupCreate { + if v != nil { + _c.SetAllowMessagesDispatch(*v) + } + return _c +} + +// SetDefaultMappedModel sets the "default_mapped_model" field. +func (_c *GroupCreate) SetDefaultMappedModel(v string) *GroupCreate { + _c.mutation.SetDefaultMappedModel(v) + return _c +} + +// SetNillableDefaultMappedModel sets the "default_mapped_model" field if the given value is not nil. +func (_c *GroupCreate) SetNillableDefaultMappedModel(v *string) *GroupCreate { + if v != nil { + _c.SetDefaultMappedModel(*v) + } + return _c +} + // AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by IDs. func (_c *GroupCreate) AddAPIKeyIDs(ids ...int64) *GroupCreate { _c.mutation.AddAPIKeyIDs(ids...) @@ -613,6 +641,14 @@ func (_c *GroupCreate) defaults() error { v := group.DefaultSortOrder _c.mutation.SetSortOrder(v) } + if _, ok := _c.mutation.AllowMessagesDispatch(); !ok { + v := group.DefaultAllowMessagesDispatch + _c.mutation.SetAllowMessagesDispatch(v) + } + if _, ok := _c.mutation.DefaultMappedModel(); !ok { + v := group.DefaultDefaultMappedModel + _c.mutation.SetDefaultMappedModel(v) + } return nil } @@ -683,6 +719,17 @@ func (_c *GroupCreate) check() error { if _, ok := _c.mutation.SortOrder(); !ok { return &ValidationError{Name: "sort_order", err: errors.New(`ent: missing required field "Group.sort_order"`)} } + if _, ok := _c.mutation.AllowMessagesDispatch(); !ok { + return &ValidationError{Name: "allow_messages_dispatch", err: errors.New(`ent: missing required field "Group.allow_messages_dispatch"`)} + } + if _, ok := _c.mutation.DefaultMappedModel(); !ok { + return &ValidationError{Name: "default_mapped_model", err: errors.New(`ent: missing required field "Group.default_mapped_model"`)} + } + if v, ok := _c.mutation.DefaultMappedModel(); ok { + if err := group.DefaultMappedModelValidator(v); err != nil { + return &ValidationError{Name: "default_mapped_model", err: fmt.Errorf(`ent: validator failed for field "Group.default_mapped_model": %w`, err)} + } + } return nil } @@ -830,6 +877,14 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) { _spec.SetField(group.FieldSortOrder, field.TypeInt, value) _node.SortOrder = value } + if value, ok := _c.mutation.AllowMessagesDispatch(); ok { + _spec.SetField(group.FieldAllowMessagesDispatch, field.TypeBool, value) + _node.AllowMessagesDispatch = value + } + if value, ok := _c.mutation.DefaultMappedModel(); ok { + _spec.SetField(group.FieldDefaultMappedModel, field.TypeString, value) + _node.DefaultMappedModel = value + } if nodes := _c.mutation.APIKeysIDs(); len(nodes) > 0 { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.O2M, @@ -1520,6 +1575,30 @@ func (u *GroupUpsert) AddSortOrder(v int) *GroupUpsert { return u } +// SetAllowMessagesDispatch sets the "allow_messages_dispatch" field. +func (u *GroupUpsert) SetAllowMessagesDispatch(v bool) *GroupUpsert { + u.Set(group.FieldAllowMessagesDispatch, v) + return u +} + +// UpdateAllowMessagesDispatch sets the "allow_messages_dispatch" field to the value that was provided on create. +func (u *GroupUpsert) UpdateAllowMessagesDispatch() *GroupUpsert { + u.SetExcluded(group.FieldAllowMessagesDispatch) + return u +} + +// SetDefaultMappedModel sets the "default_mapped_model" field. +func (u *GroupUpsert) SetDefaultMappedModel(v string) *GroupUpsert { + u.Set(group.FieldDefaultMappedModel, v) + return u +} + +// UpdateDefaultMappedModel sets the "default_mapped_model" field to the value that was provided on create. +func (u *GroupUpsert) UpdateDefaultMappedModel() *GroupUpsert { + u.SetExcluded(group.FieldDefaultMappedModel) + return u +} + // UpdateNewValues updates the mutable fields using the new values that were set on create. // Using this option is equivalent to using: // @@ -2188,6 +2267,34 @@ func (u *GroupUpsertOne) UpdateSortOrder() *GroupUpsertOne { }) } +// SetAllowMessagesDispatch sets the "allow_messages_dispatch" field. +func (u *GroupUpsertOne) SetAllowMessagesDispatch(v bool) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.SetAllowMessagesDispatch(v) + }) +} + +// UpdateAllowMessagesDispatch sets the "allow_messages_dispatch" field to the value that was provided on create. +func (u *GroupUpsertOne) UpdateAllowMessagesDispatch() *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.UpdateAllowMessagesDispatch() + }) +} + +// SetDefaultMappedModel sets the "default_mapped_model" field. +func (u *GroupUpsertOne) SetDefaultMappedModel(v string) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.SetDefaultMappedModel(v) + }) +} + +// UpdateDefaultMappedModel sets the "default_mapped_model" field to the value that was provided on create. +func (u *GroupUpsertOne) UpdateDefaultMappedModel() *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.UpdateDefaultMappedModel() + }) +} + // Exec executes the query. func (u *GroupUpsertOne) Exec(ctx context.Context) error { if len(u.create.conflict) == 0 { @@ -3022,6 +3129,34 @@ func (u *GroupUpsertBulk) UpdateSortOrder() *GroupUpsertBulk { }) } +// SetAllowMessagesDispatch sets the "allow_messages_dispatch" field. +func (u *GroupUpsertBulk) SetAllowMessagesDispatch(v bool) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.SetAllowMessagesDispatch(v) + }) +} + +// UpdateAllowMessagesDispatch sets the "allow_messages_dispatch" field to the value that was provided on create. +func (u *GroupUpsertBulk) UpdateAllowMessagesDispatch() *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.UpdateAllowMessagesDispatch() + }) +} + +// SetDefaultMappedModel sets the "default_mapped_model" field. +func (u *GroupUpsertBulk) SetDefaultMappedModel(v string) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.SetDefaultMappedModel(v) + }) +} + +// UpdateDefaultMappedModel sets the "default_mapped_model" field to the value that was provided on create. +func (u *GroupUpsertBulk) UpdateDefaultMappedModel() *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.UpdateDefaultMappedModel() + }) +} + // Exec executes the query. func (u *GroupUpsertBulk) Exec(ctx context.Context) error { if u.create.err != nil { diff --git a/backend/ent/group_update.go b/backend/ent/group_update.go index 85575292..b3698596 100644 --- a/backend/ent/group_update.go +++ b/backend/ent/group_update.go @@ -625,6 +625,34 @@ func (_u *GroupUpdate) AddSortOrder(v int) *GroupUpdate { return _u } +// SetAllowMessagesDispatch sets the "allow_messages_dispatch" field. +func (_u *GroupUpdate) SetAllowMessagesDispatch(v bool) *GroupUpdate { + _u.mutation.SetAllowMessagesDispatch(v) + return _u +} + +// SetNillableAllowMessagesDispatch sets the "allow_messages_dispatch" field if the given value is not nil. +func (_u *GroupUpdate) SetNillableAllowMessagesDispatch(v *bool) *GroupUpdate { + if v != nil { + _u.SetAllowMessagesDispatch(*v) + } + return _u +} + +// SetDefaultMappedModel sets the "default_mapped_model" field. +func (_u *GroupUpdate) SetDefaultMappedModel(v string) *GroupUpdate { + _u.mutation.SetDefaultMappedModel(v) + return _u +} + +// SetNillableDefaultMappedModel sets the "default_mapped_model" field if the given value is not nil. +func (_u *GroupUpdate) SetNillableDefaultMappedModel(v *string) *GroupUpdate { + if v != nil { + _u.SetDefaultMappedModel(*v) + } + return _u +} + // AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by IDs. func (_u *GroupUpdate) AddAPIKeyIDs(ids ...int64) *GroupUpdate { _u.mutation.AddAPIKeyIDs(ids...) @@ -910,6 +938,11 @@ func (_u *GroupUpdate) check() error { return &ValidationError{Name: "subscription_type", err: fmt.Errorf(`ent: validator failed for field "Group.subscription_type": %w`, err)} } } + if v, ok := _u.mutation.DefaultMappedModel(); ok { + if err := group.DefaultMappedModelValidator(v); err != nil { + return &ValidationError{Name: "default_mapped_model", err: fmt.Errorf(`ent: validator failed for field "Group.default_mapped_model": %w`, err)} + } + } return nil } @@ -1110,6 +1143,12 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) { if value, ok := _u.mutation.AddedSortOrder(); ok { _spec.AddField(group.FieldSortOrder, field.TypeInt, value) } + if value, ok := _u.mutation.AllowMessagesDispatch(); ok { + _spec.SetField(group.FieldAllowMessagesDispatch, field.TypeBool, value) + } + if value, ok := _u.mutation.DefaultMappedModel(); ok { + _spec.SetField(group.FieldDefaultMappedModel, field.TypeString, value) + } if _u.mutation.APIKeysCleared() { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.O2M, @@ -2014,6 +2053,34 @@ func (_u *GroupUpdateOne) AddSortOrder(v int) *GroupUpdateOne { return _u } +// SetAllowMessagesDispatch sets the "allow_messages_dispatch" field. +func (_u *GroupUpdateOne) SetAllowMessagesDispatch(v bool) *GroupUpdateOne { + _u.mutation.SetAllowMessagesDispatch(v) + return _u +} + +// SetNillableAllowMessagesDispatch sets the "allow_messages_dispatch" field if the given value is not nil. +func (_u *GroupUpdateOne) SetNillableAllowMessagesDispatch(v *bool) *GroupUpdateOne { + if v != nil { + _u.SetAllowMessagesDispatch(*v) + } + return _u +} + +// SetDefaultMappedModel sets the "default_mapped_model" field. +func (_u *GroupUpdateOne) SetDefaultMappedModel(v string) *GroupUpdateOne { + _u.mutation.SetDefaultMappedModel(v) + return _u +} + +// SetNillableDefaultMappedModel sets the "default_mapped_model" field if the given value is not nil. +func (_u *GroupUpdateOne) SetNillableDefaultMappedModel(v *string) *GroupUpdateOne { + if v != nil { + _u.SetDefaultMappedModel(*v) + } + return _u +} + // AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by IDs. func (_u *GroupUpdateOne) AddAPIKeyIDs(ids ...int64) *GroupUpdateOne { _u.mutation.AddAPIKeyIDs(ids...) @@ -2312,6 +2379,11 @@ func (_u *GroupUpdateOne) check() error { return &ValidationError{Name: "subscription_type", err: fmt.Errorf(`ent: validator failed for field "Group.subscription_type": %w`, err)} } } + if v, ok := _u.mutation.DefaultMappedModel(); ok { + if err := group.DefaultMappedModelValidator(v); err != nil { + return &ValidationError{Name: "default_mapped_model", err: fmt.Errorf(`ent: validator failed for field "Group.default_mapped_model": %w`, err)} + } + } return nil } @@ -2529,6 +2601,12 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error) if value, ok := _u.mutation.AddedSortOrder(); ok { _spec.AddField(group.FieldSortOrder, field.TypeInt, value) } + if value, ok := _u.mutation.AllowMessagesDispatch(); ok { + _spec.SetField(group.FieldAllowMessagesDispatch, field.TypeBool, value) + } + if value, ok := _u.mutation.DefaultMappedModel(); ok { + _spec.SetField(group.FieldDefaultMappedModel, field.TypeString, value) + } if _u.mutation.APIKeysCleared() { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.O2M, diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index 65d44d5a..ff1c1b88 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -408,6 +408,8 @@ var ( {Name: "mcp_xml_inject", Type: field.TypeBool, Default: true}, {Name: "supported_model_scopes", Type: field.TypeJSON, SchemaType: map[string]string{"postgres": "jsonb"}}, {Name: "sort_order", Type: field.TypeInt, Default: 0}, + {Name: "allow_messages_dispatch", Type: field.TypeBool, Default: false}, + {Name: "default_mapped_model", Type: field.TypeString, Size: 100, Default: ""}, } // GroupsTable holds the schema information for the "groups" table. GroupsTable = &schema.Table{ diff --git a/backend/ent/mutation.go b/backend/ent/mutation.go index 425c0199..652adcac 100644 --- a/backend/ent/mutation.go +++ b/backend/ent/mutation.go @@ -8250,6 +8250,8 @@ type GroupMutation struct { appendsupported_model_scopes []string sort_order *int addsort_order *int + allow_messages_dispatch *bool + default_mapped_model *string clearedFields map[string]struct{} api_keys map[int64]struct{} removedapi_keys map[int64]struct{} @@ -9994,6 +9996,78 @@ func (m *GroupMutation) ResetSortOrder() { m.addsort_order = nil } +// SetAllowMessagesDispatch sets the "allow_messages_dispatch" field. +func (m *GroupMutation) SetAllowMessagesDispatch(b bool) { + m.allow_messages_dispatch = &b +} + +// AllowMessagesDispatch returns the value of the "allow_messages_dispatch" field in the mutation. +func (m *GroupMutation) AllowMessagesDispatch() (r bool, exists bool) { + v := m.allow_messages_dispatch + if v == nil { + return + } + return *v, true +} + +// OldAllowMessagesDispatch returns the old "allow_messages_dispatch" 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) OldAllowMessagesDispatch(ctx context.Context) (v bool, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldAllowMessagesDispatch is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldAllowMessagesDispatch requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldAllowMessagesDispatch: %w", err) + } + return oldValue.AllowMessagesDispatch, nil +} + +// ResetAllowMessagesDispatch resets all changes to the "allow_messages_dispatch" field. +func (m *GroupMutation) ResetAllowMessagesDispatch() { + m.allow_messages_dispatch = nil +} + +// SetDefaultMappedModel sets the "default_mapped_model" field. +func (m *GroupMutation) SetDefaultMappedModel(s string) { + m.default_mapped_model = &s +} + +// DefaultMappedModel returns the value of the "default_mapped_model" field in the mutation. +func (m *GroupMutation) DefaultMappedModel() (r string, exists bool) { + v := m.default_mapped_model + if v == nil { + return + } + return *v, true +} + +// OldDefaultMappedModel returns the old "default_mapped_model" 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) OldDefaultMappedModel(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldDefaultMappedModel is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldDefaultMappedModel requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldDefaultMappedModel: %w", err) + } + return oldValue.DefaultMappedModel, nil +} + +// ResetDefaultMappedModel resets all changes to the "default_mapped_model" field. +func (m *GroupMutation) ResetDefaultMappedModel() { + m.default_mapped_model = nil +} + // AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by ids. func (m *GroupMutation) AddAPIKeyIDs(ids ...int64) { if m.api_keys == nil { @@ -10352,7 +10426,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, 30) + fields := make([]string, 0, 32) if m.created_at != nil { fields = append(fields, group.FieldCreatedAt) } @@ -10443,6 +10517,12 @@ func (m *GroupMutation) Fields() []string { if m.sort_order != nil { fields = append(fields, group.FieldSortOrder) } + if m.allow_messages_dispatch != nil { + fields = append(fields, group.FieldAllowMessagesDispatch) + } + if m.default_mapped_model != nil { + fields = append(fields, group.FieldDefaultMappedModel) + } return fields } @@ -10511,6 +10591,10 @@ func (m *GroupMutation) Field(name string) (ent.Value, bool) { return m.SupportedModelScopes() case group.FieldSortOrder: return m.SortOrder() + case group.FieldAllowMessagesDispatch: + return m.AllowMessagesDispatch() + case group.FieldDefaultMappedModel: + return m.DefaultMappedModel() } return nil, false } @@ -10580,6 +10664,10 @@ func (m *GroupMutation) OldField(ctx context.Context, name string) (ent.Value, e return m.OldSupportedModelScopes(ctx) case group.FieldSortOrder: return m.OldSortOrder(ctx) + case group.FieldAllowMessagesDispatch: + return m.OldAllowMessagesDispatch(ctx) + case group.FieldDefaultMappedModel: + return m.OldDefaultMappedModel(ctx) } return nil, fmt.Errorf("unknown Group field %s", name) } @@ -10799,6 +10887,20 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error { } m.SetSortOrder(v) return nil + case group.FieldAllowMessagesDispatch: + v, ok := value.(bool) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetAllowMessagesDispatch(v) + return nil + case group.FieldDefaultMappedModel: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetDefaultMappedModel(v) + return nil } return fmt.Errorf("unknown Group field %s", name) } @@ -11226,6 +11328,12 @@ func (m *GroupMutation) ResetField(name string) error { case group.FieldSortOrder: m.ResetSortOrder() return nil + case group.FieldAllowMessagesDispatch: + m.ResetAllowMessagesDispatch() + return nil + case group.FieldDefaultMappedModel: + m.ResetDefaultMappedModel() + return nil } return fmt.Errorf("unknown Group field %s", name) } diff --git a/backend/ent/runtime/runtime.go b/backend/ent/runtime/runtime.go index 8d231e7c..b8facf36 100644 --- a/backend/ent/runtime/runtime.go +++ b/backend/ent/runtime/runtime.go @@ -453,6 +453,16 @@ func init() { groupDescSortOrder := groupFields[26].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[27].Descriptor() + // group.DefaultAllowMessagesDispatch holds the default value on creation for the allow_messages_dispatch field. + group.DefaultAllowMessagesDispatch = groupDescAllowMessagesDispatch.Default.(bool) + // groupDescDefaultMappedModel is the schema descriptor for default_mapped_model field. + groupDescDefaultMappedModel := groupFields[28].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) idempotencyrecordMixin := schema.IdempotencyRecord{}.Mixin() idempotencyrecordMixinFields0 := idempotencyrecordMixin[0].Fields() _ = idempotencyrecordMixinFields0 diff --git a/backend/ent/schema/group.go b/backend/ent/schema/group.go index 3fcf8674..0f5a7b14 100644 --- a/backend/ent/schema/group.go +++ b/backend/ent/schema/group.go @@ -148,6 +148,15 @@ func (Group) Fields() []ent.Field { field.Int("sort_order"). Default(0). Comment("分组显示排序,数值越小越靠前"), + + // OpenAI Messages 调度配置 (added by migration 069) + field.Bool("allow_messages_dispatch"). + Default(false). + Comment("是否允许 /v1/messages 调度到此 OpenAI 分组"), + field.String("default_mapped_model"). + MaxLen(100). + Default(""). + Comment("默认映射模型 ID,当账号级映射找不到时使用此值"), } } diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go index 1edf4dcc..734acaaa 100644 --- a/backend/internal/handler/admin/group_handler.go +++ b/backend/internal/handler/admin/group_handler.go @@ -53,6 +53,9 @@ type CreateGroupRequest struct { SupportedModelScopes []string `json:"supported_model_scopes"` // Sora 存储配额 SoraStorageQuotaBytes int64 `json:"sora_storage_quota_bytes"` + // OpenAI Messages 调度配置(仅 openai 平台使用) + AllowMessagesDispatch bool `json:"allow_messages_dispatch"` + DefaultMappedModel string `json:"default_mapped_model"` // 从指定分组复制账号(创建后自动绑定) CopyAccountsFromGroupIDs []int64 `json:"copy_accounts_from_group_ids"` } @@ -88,6 +91,9 @@ type UpdateGroupRequest struct { SupportedModelScopes *[]string `json:"supported_model_scopes"` // Sora 存储配额 SoraStorageQuotaBytes *int64 `json:"sora_storage_quota_bytes"` + // OpenAI Messages 调度配置(仅 openai 平台使用) + AllowMessagesDispatch *bool `json:"allow_messages_dispatch"` + DefaultMappedModel *string `json:"default_mapped_model"` // 从指定分组复制账号(同步操作:先清空当前分组的账号绑定,再绑定源分组的账号) CopyAccountsFromGroupIDs []int64 `json:"copy_accounts_from_group_ids"` } @@ -203,6 +209,8 @@ func (h *GroupHandler) Create(c *gin.Context) { MCPXMLInject: req.MCPXMLInject, SupportedModelScopes: req.SupportedModelScopes, SoraStorageQuotaBytes: req.SoraStorageQuotaBytes, + AllowMessagesDispatch: req.AllowMessagesDispatch, + DefaultMappedModel: req.DefaultMappedModel, CopyAccountsFromGroupIDs: req.CopyAccountsFromGroupIDs, }) if err != nil { @@ -254,6 +262,8 @@ func (h *GroupHandler) Update(c *gin.Context) { MCPXMLInject: req.MCPXMLInject, SupportedModelScopes: req.SupportedModelScopes, SoraStorageQuotaBytes: req.SoraStorageQuotaBytes, + AllowMessagesDispatch: req.AllowMessagesDispatch, + DefaultMappedModel: req.DefaultMappedModel, CopyAccountsFromGroupIDs: req.CopyAccountsFromGroupIDs, }) if err != nil { diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index 31a02cca..2cae9817 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -125,8 +125,9 @@ func GroupFromServiceAdmin(g *service.Group) *AdminGroup { Group: groupFromServiceBase(g), ModelRouting: g.ModelRouting, ModelRoutingEnabled: g.ModelRoutingEnabled, - MCPXMLInject: g.MCPXMLInject, - SupportedModelScopes: g.SupportedModelScopes, + MCPXMLInject: g.MCPXMLInject, + DefaultMappedModel: g.DefaultMappedModel, + SupportedModelScopes: g.SupportedModelScopes, AccountCount: g.AccountCount, SortOrder: g.SortOrder, } @@ -164,6 +165,7 @@ func groupFromServiceBase(g *service.Group) Group { FallbackGroupID: g.FallbackGroupID, FallbackGroupIDOnInvalidRequest: g.FallbackGroupIDOnInvalidRequest, SoraStorageQuotaBytes: g.SoraStorageQuotaBytes, + AllowMessagesDispatch: g.AllowMessagesDispatch, CreatedAt: g.CreatedAt, UpdatedAt: g.UpdatedAt, } diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index e7835170..1c68f429 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -96,6 +96,9 @@ type Group struct { // Sora 存储配额 SoraStorageQuotaBytes int64 `json:"sora_storage_quota_bytes"` + // OpenAI Messages 调度开关(用户侧需要此字段判断是否展示 Claude Code 教程) + AllowMessagesDispatch bool `json:"allow_messages_dispatch"` + CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } @@ -112,6 +115,9 @@ type AdminGroup struct { // MCP XML 协议注入(仅 antigravity 平台使用) MCPXMLInject bool `json:"mcp_xml_inject"` + // OpenAI Messages 调度配置(仅 openai 平台使用) + DefaultMappedModel string `json:"default_mapped_model"` + // 支持的模型系列(仅 antigravity 平台使用) SupportedModelScopes []string `json:"supported_model_scopes"` AccountGroups []AccountGroup `json:"account_groups,omitempty"` diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index 91d5d465..ffd8b166 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -467,6 +467,14 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) { zap.Int64("api_key_id", apiKey.ID), zap.Any("group_id", apiKey.GroupID), ) + + // 检查分组是否允许 /v1/messages 调度 + if apiKey.Group != nil && !apiKey.Group.AllowMessagesDispatch { + h.anthropicErrorResponse(c, http.StatusForbidden, "permission_error", + "This group does not allow /v1/messages dispatch") + return + } + if !h.ensureResponsesDependencies(c, reqLog) { return } @@ -536,6 +544,8 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) { var lastFailoverErr *service.UpstreamFailoverError for { + // 清除上一次迭代的降级模型标记,避免残留影响本次迭代 + c.Set("openai_messages_fallback_model", "") reqLog.Debug("openai_messages.account_selecting", zap.Int("excluded_account_count", len(failedAccountIDs))) selection, scheduleDecision, err := h.gatewayService.SelectAccountWithScheduler( c.Request.Context(), @@ -551,16 +561,41 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) { zap.Error(err), zap.Int("excluded_account_count", len(failedAccountIDs)), ) + // 首次调度失败 + 有默认映射模型 → 用默认模型重试 if len(failedAccountIDs) == 0 { - h.anthropicStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "Service temporarily unavailable", streamStarted) + defaultModel := "" + if apiKey.Group != nil { + defaultModel = apiKey.Group.DefaultMappedModel + } + if defaultModel != "" && defaultModel != reqModel { + reqLog.Info("openai_messages.fallback_to_default_model", + zap.String("default_mapped_model", defaultModel), + ) + selection, scheduleDecision, err = h.gatewayService.SelectAccountWithScheduler( + c.Request.Context(), + apiKey.GroupID, + "", + sessionHash, + defaultModel, + failedAccountIDs, + service.OpenAIUpstreamTransportAny, + ) + if err == nil && selection != nil { + c.Set("openai_messages_fallback_model", defaultModel) + } + } + if err != nil { + h.anthropicStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "Service temporarily unavailable", streamStarted) + return + } + } else { + if lastFailoverErr != nil { + h.handleAnthropicFailoverExhausted(c, lastFailoverErr, streamStarted) + } else { + h.anthropicStreamingAwareError(c, http.StatusBadGateway, "api_error", "Upstream request failed", streamStarted) + } return } - if lastFailoverErr != nil { - h.handleAnthropicFailoverExhausted(c, lastFailoverErr, streamStarted) - } else { - h.anthropicStreamingAwareError(c, http.StatusBadGateway, "api_error", "Upstream request failed", streamStarted) - } - return } if selection == nil || selection.Account == nil { h.anthropicStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts", streamStarted) @@ -579,7 +614,15 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) { service.SetOpsLatencyMs(c, service.OpsRoutingLatencyMsKey, time.Since(routingStart).Milliseconds()) forwardStart := time.Now() - result, err := h.gatewayService.ForwardAsAnthropic(c.Request.Context(), c, account, body, promptCacheKey) + defaultMappedModel := "" + if apiKey.Group != nil { + defaultMappedModel = apiKey.Group.DefaultMappedModel + } + // 如果使用了降级模型调度,强制使用降级模型 + if fallbackModel := c.GetString("openai_messages_fallback_model"); fallbackModel != "" { + defaultMappedModel = fallbackModel + } + result, err := h.gatewayService.ForwardAsAnthropic(c.Request.Context(), c, account, body, promptCacheKey, defaultMappedModel) forwardDurationMs := time.Since(forwardStart).Milliseconds() if accountReleaseFunc != nil { diff --git a/backend/internal/repository/api_key_repo.go b/backend/internal/repository/api_key_repo.go index d9732f68..6991bf99 100644 --- a/backend/internal/repository/api_key_repo.go +++ b/backend/internal/repository/api_key_repo.go @@ -165,6 +165,8 @@ func (r *apiKeyRepository) GetByKeyForAuth(ctx context.Context, key string) (*se group.FieldModelRouting, group.FieldMcpXMLInject, group.FieldSupportedModelScopes, + group.FieldAllowMessagesDispatch, + group.FieldDefaultMappedModel, ) }). Only(ctx) @@ -619,6 +621,8 @@ func groupEntityToService(g *dbent.Group) *service.Group { MCPXMLInject: g.McpXMLInject, SupportedModelScopes: g.SupportedModelScopes, SortOrder: g.SortOrder, + AllowMessagesDispatch: g.AllowMessagesDispatch, + DefaultMappedModel: g.DefaultMappedModel, CreatedAt: g.CreatedAt, UpdatedAt: g.UpdatedAt, } diff --git a/backend/internal/repository/group_repo.go b/backend/internal/repository/group_repo.go index 4edc8534..c195f1f1 100644 --- a/backend/internal/repository/group_repo.go +++ b/backend/internal/repository/group_repo.go @@ -59,7 +59,9 @@ func (r *groupRepository) Create(ctx context.Context, groupIn *service.Group) er SetNillableFallbackGroupIDOnInvalidRequest(groupIn.FallbackGroupIDOnInvalidRequest). SetModelRoutingEnabled(groupIn.ModelRoutingEnabled). SetMcpXMLInject(groupIn.MCPXMLInject). - SetSoraStorageQuotaBytes(groupIn.SoraStorageQuotaBytes) + SetSoraStorageQuotaBytes(groupIn.SoraStorageQuotaBytes). + SetAllowMessagesDispatch(groupIn.AllowMessagesDispatch). + SetDefaultMappedModel(groupIn.DefaultMappedModel) // 设置模型路由配置 if groupIn.ModelRouting != nil { @@ -125,7 +127,9 @@ func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) er SetClaudeCodeOnly(groupIn.ClaudeCodeOnly). SetModelRoutingEnabled(groupIn.ModelRoutingEnabled). SetMcpXMLInject(groupIn.MCPXMLInject). - SetSoraStorageQuotaBytes(groupIn.SoraStorageQuotaBytes) + SetSoraStorageQuotaBytes(groupIn.SoraStorageQuotaBytes). + SetAllowMessagesDispatch(groupIn.AllowMessagesDispatch). + SetDefaultMappedModel(groupIn.DefaultMappedModel) // 显式处理可空字段:nil 需要 clear,非 nil 需要 set。 if groupIn.DailyLimitUSD != nil { diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 446cc148..680268e0 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -145,6 +145,9 @@ type CreateGroupInput struct { SupportedModelScopes []string // Sora 存储配额 SoraStorageQuotaBytes int64 + // OpenAI Messages 调度配置(仅 openai 平台使用) + AllowMessagesDispatch bool + DefaultMappedModel string // 从指定分组复制账号(创建分组后在同一事务内绑定) CopyAccountsFromGroupIDs []int64 } @@ -181,6 +184,9 @@ type UpdateGroupInput struct { SupportedModelScopes *[]string // Sora 存储配额 SoraStorageQuotaBytes *int64 + // OpenAI Messages 调度配置(仅 openai 平台使用) + AllowMessagesDispatch *bool + DefaultMappedModel *string // 从指定分组复制账号(同步操作:先清空当前分组的账号绑定,再绑定源分组的账号) CopyAccountsFromGroupIDs []int64 } @@ -909,6 +915,8 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn MCPXMLInject: mcpXMLInject, SupportedModelScopes: input.SupportedModelScopes, SoraStorageQuotaBytes: input.SoraStorageQuotaBytes, + AllowMessagesDispatch: input.AllowMessagesDispatch, + DefaultMappedModel: input.DefaultMappedModel, } if err := s.groupRepo.Create(ctx, group); err != nil { return nil, err @@ -1122,6 +1130,14 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd group.SupportedModelScopes = *input.SupportedModelScopes } + // OpenAI Messages 调度配置 + if input.AllowMessagesDispatch != nil { + group.AllowMessagesDispatch = *input.AllowMessagesDispatch + } + if input.DefaultMappedModel != nil { + group.DefaultMappedModel = *input.DefaultMappedModel + } + if err := s.groupRepo.Update(ctx, group); err != nil { return nil, err } diff --git a/backend/internal/service/api_key_auth_cache.go b/backend/internal/service/api_key_auth_cache.go index 83933f42..e8ad5c9c 100644 --- a/backend/internal/service/api_key_auth_cache.go +++ b/backend/internal/service/api_key_auth_cache.go @@ -65,6 +65,10 @@ type APIKeyAuthGroupSnapshot struct { // 支持的模型系列(仅 antigravity 平台使用) SupportedModelScopes []string `json:"supported_model_scopes,omitempty"` + + // OpenAI Messages 调度配置(仅 openai 平台使用) + AllowMessagesDispatch bool `json:"allow_messages_dispatch"` + DefaultMappedModel string `json:"default_mapped_model,omitempty"` } // APIKeyAuthCacheEntry 缓存条目,支持负缓存 diff --git a/backend/internal/service/api_key_auth_cache_impl.go b/backend/internal/service/api_key_auth_cache_impl.go index 0ca694af..f727ab10 100644 --- a/backend/internal/service/api_key_auth_cache_impl.go +++ b/backend/internal/service/api_key_auth_cache_impl.go @@ -245,6 +245,8 @@ func (s *APIKeyService) snapshotFromAPIKey(apiKey *APIKey) *APIKeyAuthSnapshot { ModelRoutingEnabled: apiKey.Group.ModelRoutingEnabled, MCPXMLInject: apiKey.Group.MCPXMLInject, SupportedModelScopes: apiKey.Group.SupportedModelScopes, + AllowMessagesDispatch: apiKey.Group.AllowMessagesDispatch, + DefaultMappedModel: apiKey.Group.DefaultMappedModel, } } return snapshot @@ -302,6 +304,8 @@ func (s *APIKeyService) snapshotToAPIKey(key string, snapshot *APIKeyAuthSnapsho ModelRoutingEnabled: snapshot.Group.ModelRoutingEnabled, MCPXMLInject: snapshot.Group.MCPXMLInject, SupportedModelScopes: snapshot.Group.SupportedModelScopes, + AllowMessagesDispatch: snapshot.Group.AllowMessagesDispatch, + DefaultMappedModel: snapshot.Group.DefaultMappedModel, } } s.compileAPIKeyIPRules(apiKey) diff --git a/backend/internal/service/group.go b/backend/internal/service/group.go index 6990caca..537b5a3b 100644 --- a/backend/internal/service/group.go +++ b/backend/internal/service/group.go @@ -57,6 +57,10 @@ type Group struct { // 分组排序 SortOrder int + // OpenAI Messages 调度配置(仅 openai 平台使用) + AllowMessagesDispatch bool + DefaultMappedModel string + CreatedAt time.Time UpdatedAt time.Time diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index c2021f63..fe97b734 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -28,6 +28,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( account *Account, body []byte, promptCacheKey string, + defaultMappedModel string, ) (*OpenAIForwardResult, error) { startTime := time.Now() @@ -47,6 +48,10 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( // 3. Model mapping mappedModel := account.GetMappedModel(originalModel) + // 分组级降级:账号未映射时使用分组默认映射模型 + if mappedModel == originalModel && defaultMappedModel != "" { + mappedModel = defaultMappedModel + } responsesReq.Model = mappedModel logger.L().Debug("openai messages: model mapping applied", diff --git a/backend/migrations/069_add_group_messages_dispatch.sql b/backend/migrations/069_add_group_messages_dispatch.sql new file mode 100644 index 00000000..7b9d5f5d --- /dev/null +++ b/backend/migrations/069_add_group_messages_dispatch.sql @@ -0,0 +1,2 @@ +ALTER TABLE groups ADD COLUMN allow_messages_dispatch BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE groups ADD COLUMN default_mapped_model VARCHAR(100) NOT NULL DEFAULT ''; diff --git a/frontend/src/components/keys/UseKeyModal.vue b/frontend/src/components/keys/UseKeyModal.vue index 46d2d597..8d59bc5e 100644 --- a/frontend/src/components/keys/UseKeyModal.vue +++ b/frontend/src/components/keys/UseKeyModal.vue @@ -146,6 +146,7 @@ interface Props { apiKey: string baseUrl: string platform: GroupPlatform | null + allowMessagesDispatch?: boolean } interface Emits { @@ -265,13 +266,17 @@ const SparkleIcon = { const clientTabs = computed((): TabConfig[] => { if (!props.platform) return [] switch (props.platform) { - case 'openai': - return [ + case 'openai': { + const tabs: TabConfig[] = [ { id: 'codex', label: t('keys.useKeyModal.cliTabs.codexCli'), icon: TerminalIcon }, { id: 'codex-ws', label: t('keys.useKeyModal.cliTabs.codexCliWs'), icon: TerminalIcon }, - { id: 'claude', label: t('keys.useKeyModal.cliTabs.claudeCode'), icon: TerminalIcon }, - { id: 'opencode', label: t('keys.useKeyModal.cliTabs.opencode'), icon: TerminalIcon } ] + if (props.allowMessagesDispatch) { + tabs.push({ id: 'claude', label: t('keys.useKeyModal.cliTabs.claudeCode'), icon: TerminalIcon }) + } + tabs.push({ id: 'opencode', label: t('keys.useKeyModal.cliTabs.opencode'), icon: TerminalIcon }) + return tabs + } case 'gemini': return [ { id: 'gemini', label: t('keys.useKeyModal.cliTabs.geminiCli'), icon: SparkleIcon }, diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 71b2be82..1efff120 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -1443,6 +1443,14 @@ export default { fallbackHint: 'Non-Claude Code requests will use this group. Leave empty to reject directly.', noFallback: 'No Fallback (Reject)' }, + openaiMessages: { + title: 'OpenAI Messages Dispatch', + allowDispatch: 'Allow /v1/messages dispatch', + allowDispatchHint: 'When enabled, API keys in this OpenAI group can dispatch requests through /v1/messages endpoint', + defaultModel: 'Default mapped model', + defaultModelPlaceholder: 'e.g., gpt-4.1', + defaultModelHint: 'When account has no model mapping configured, all request models will be mapped to this model' + }, invalidRequestFallback: { title: 'Invalid Request Fallback Group', hint: 'Triggered only when upstream explicitly returns prompt too long. Leave empty to disable fallback.', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 331a43e8..b2c38928 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -1530,6 +1530,14 @@ export default { fallbackHint: '非 Claude Code 请求将使用此分组,留空则直接拒绝', noFallback: '不降级(直接拒绝)' }, + openaiMessages: { + title: 'OpenAI Messages 调度配置', + allowDispatch: '允许 /v1/messages 调度', + allowDispatchHint: '启用后,此 OpenAI 分组的 API Key 可以通过 /v1/messages 端点调度请求', + defaultModel: '默认映射模型', + defaultModelPlaceholder: '例如: gpt-4.1', + defaultModelHint: '当账号未配置模型映射时,所有请求模型将映射到此模型' + }, invalidRequestFallback: { title: '无效请求兜底分组', hint: '仅当上游明确返回 prompt too long 时才会触发,留空表示不兜底', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 18075643..2d8a2487 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -389,6 +389,8 @@ export interface Group { claude_code_only: boolean fallback_group_id: number | null fallback_group_id_on_invalid_request: number | null + // OpenAI Messages 调度开关(用户侧需要此字段判断是否展示 Claude Code 教程) + allow_messages_dispatch?: boolean created_at: string updated_at: string } @@ -407,6 +409,9 @@ export interface AdminGroup extends Group { // 分组下账号数量(仅管理员可见) account_count?: number + // OpenAI Messages 调度配置(仅 openai 平台使用) + default_mapped_model?: string + // 分组排序 sort_order: number } diff --git a/frontend/src/views/admin/GroupsView.vue b/frontend/src/views/admin/GroupsView.vue index aa0a49a7..01b98c0c 100644 --- a/frontend/src/views/admin/GroupsView.vue +++ b/frontend/src/views/admin/GroupsView.vue @@ -708,6 +708,44 @@ + +
+

{{ t('admin.groups.openaiMessages.title') }}

+ + +
+ + +
+

{{ t('admin.groups.openaiMessages.allowDispatchHint') }}

+ + +
+ + +

{{ t('admin.groups.openaiMessages.defaultModelHint') }}

+
+
+
+ +
+

{{ t('admin.groups.openaiMessages.title') }}

+ + +
+ + +
+

{{ t('admin.groups.openaiMessages.allowDispatchHint') }}

+ + +
+ + +

{{ t('admin.groups.openaiMessages.defaultModelHint') }}

+
+
+
{ createForm.claude_code_only = false createForm.fallback_group_id = null createForm.fallback_group_id_on_invalid_request = null + createForm.allow_messages_dispatch = false + createForm.default_mapped_model = 'gpt-5.4' createForm.supported_model_scopes = ['claude', 'gemini_text', 'gemini_image'] createForm.mcp_xml_inject = true createForm.copy_accounts_from_group_ids = [] @@ -2320,6 +2404,8 @@ const handleEdit = async (group: AdminGroup) => { editForm.claude_code_only = group.claude_code_only || false editForm.fallback_group_id = group.fallback_group_id editForm.fallback_group_id_on_invalid_request = group.fallback_group_id_on_invalid_request + editForm.allow_messages_dispatch = group.allow_messages_dispatch || false + editForm.default_mapped_model = group.default_mapped_model || '' editForm.model_routing_enabled = group.model_routing_enabled || false editForm.supported_model_scopes = group.supported_model_scopes || ['claude', 'gemini_text', 'gemini_image'] editForm.mcp_xml_inject = group.mcp_xml_inject ?? true @@ -2410,6 +2496,10 @@ watch( if (!['anthropic', 'antigravity'].includes(newVal)) { createForm.fallback_group_id_on_invalid_request = null } + if (newVal !== 'openai') { + createForm.allow_messages_dispatch = false + createForm.default_mapped_model = '' + } } ) diff --git a/frontend/src/views/user/KeysView.vue b/frontend/src/views/user/KeysView.vue index 26d44f11..307a43b6 100644 --- a/frontend/src/views/user/KeysView.vue +++ b/frontend/src/views/user/KeysView.vue @@ -899,6 +899,7 @@ :api-key="selectedKey?.key || ''" :base-url="publicSettings?.api_base_url || ''" :platform="selectedKey?.group?.platform || null" + :allow-messages-dispatch="selectedKey?.group?.allow_messages_dispatch || false" @close="closeUseKeyModal" />